Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b406849a9 | ||
|
|
ad11a34efc | ||
|
|
9c28db8b7b | ||
|
|
32d5fea3d3 | ||
|
|
6adc721b34 | ||
|
|
531748dcc3 | ||
|
|
9965f48ba7 | ||
|
|
d15d7549f7 | ||
|
|
8fcffd2338 | ||
|
|
07e937cd8e | ||
|
|
1f1b421735 | ||
|
|
5a43ec8411 | ||
|
|
c86e7e02bc | ||
|
|
d91d7a2f60 | ||
|
|
b3b285b1e5 | ||
|
|
11debee402 | ||
|
|
1608e4f122 | ||
|
|
edeeee1227 | ||
|
|
9ff238816b | ||
|
|
6bd5caf008 | ||
|
|
c021aff58f | ||
|
|
683be55883 | ||
|
|
970ce8cf26 | ||
|
|
cbbe1a4e4d | ||
|
|
6a74677cc9 | ||
|
|
f6ea4f931d | ||
|
|
950fcfdb40 | ||
|
|
053c895120 | ||
|
|
245167e8aa | ||
|
|
461e099bbc | ||
|
|
45e25ce18b | ||
|
|
4d88dbaf49 | ||
|
+4 |
e61ff879c4 | ||
|
|
adeb7d977d |
+1
-2
@@ -75,8 +75,7 @@ package-lock.json
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
yarn.lock
|
||||
|
||||
.npmrc
|
||||
.secrets
|
||||
|
||||
+4
-4
@@ -69,14 +69,14 @@ chmod +x setup.sh
|
||||
docker compose -f docker-compose-local.yml up
|
||||
```
|
||||
|
||||
5. Start web apps:
|
||||
4. Start web apps:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
|
||||
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
|
||||
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
|
||||
|
||||
That’s it! You’re all set to begin coding. Remember to refresh your browser if changes don’t auto-reload. Happy contributing! 🎉
|
||||
|
||||
|
||||
+11
-11
@@ -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": {
|
||||
@@ -16,13 +16,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/propel": "*",
|
||||
"@plane/services": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
@@ -42,9 +42,9 @@
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "18.16.1",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
@@ -692,6 +692,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
link = IssueLink.objects.get(pk=serializer.data["id"])
|
||||
link.created_by_id = request.data.get("created_by", request.user.id)
|
||||
@@ -719,6 +722,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
|
||||
@@ -45,7 +45,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title(
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
@@ -78,7 +78,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title(
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ def send_email_notification(
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
"entity_type": "issue",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
|
||||
@@ -19,17 +19,6 @@ logger = logging.getLogger("plane.worker")
|
||||
|
||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
@@ -57,17 +46,18 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
|
||||
}
|
||||
|
||||
# Fetch the main page
|
||||
response = requests.get(url, headers=headers, timeout=2)
|
||||
soup = None
|
||||
title = None
|
||||
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=1)
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
|
||||
# Extract title
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||
|
||||
# Fetch and encode favicon
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
|
||||
@@ -82,14 +72,6 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Request failed: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
@@ -100,7 +82,7 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
|
||||
@@ -111,18 +93,20 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
Returns:
|
||||
str: Absolute URL to favicon or None
|
||||
"""
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
if soup is not None:
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
@@ -131,7 +115,6 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = requests.head(fallback_url, timeout=2)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
@@ -142,8 +125,8 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
|
||||
|
||||
def fetch_and_encode_favicon(
|
||||
headers: Dict[str, str], soup: BeautifulSoup, url: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Fetch favicon and encode it as base64.
|
||||
|
||||
@@ -162,8 +145,7 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=2)
|
||||
response.raise_for_status()
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
@@ -183,3 +165,13 @@ def fetch_and_encode_favicon(
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.21 on 2025-06-06 12:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0096_user_is_email_valid_user_masked_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='external_id',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='external_source',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@@ -122,6 +122,9 @@ class Project(BaseModel):
|
||||
# timezone
|
||||
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
# external_id for imports
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
@property
|
||||
def cover_image_url(self):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.21
|
||||
Django==4.2.22
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -9,4 +9,4 @@ factory-boy==3.3.0
|
||||
freezegun==1.2.2
|
||||
coverage==7.2.7
|
||||
httpx==0.24.1
|
||||
requests==2.32.2
|
||||
requests==2.32.4
|
||||
@@ -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>
|
||||
|
||||
+4
-4
@@ -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",
|
||||
@@ -20,9 +20,9 @@
|
||||
"@hocuspocus/extension-logger": "^2.15.0",
|
||||
"@hocuspocus/extension-redis": "^2.15.0",
|
||||
"@hocuspocus/server": "^2.15.0",
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
"axios": "^1.8.3",
|
||||
|
||||
+3
-17
@@ -2,16 +2,9 @@
|
||||
"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": [
|
||||
"web",
|
||||
"space",
|
||||
"admin",
|
||||
"live",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --concurrency=13",
|
||||
@@ -24,14 +17,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.5.3"
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0",
|
||||
"@babel/helpers": "7.26.10",
|
||||
"@babel/runtime": "7.26.10",
|
||||
"chokidar": "3.6.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -136,45 +136,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
|
||||
],
|
||||
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||
display_filters: {
|
||||
group_by: [
|
||||
"state",
|
||||
"cycle",
|
||||
"module",
|
||||
"state_detail.group",
|
||||
"priority",
|
||||
"labels",
|
||||
"assignees",
|
||||
"created_by",
|
||||
null,
|
||||
],
|
||||
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
|
||||
type: [null, "active", "backlog"],
|
||||
},
|
||||
extra_options: {
|
||||
access: true,
|
||||
values: ["show_empty_groups"],
|
||||
},
|
||||
},
|
||||
},
|
||||
draft_issues: {
|
||||
list: {
|
||||
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
|
||||
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||
display_filters: {
|
||||
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null],
|
||||
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
|
||||
type: [null, "active", "backlog"],
|
||||
},
|
||||
extra_options: {
|
||||
access: true,
|
||||
values: ["show_empty_groups"],
|
||||
},
|
||||
},
|
||||
kanban: {
|
||||
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
|
||||
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
|
||||
display_filters: {
|
||||
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"],
|
||||
group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null],
|
||||
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
|
||||
type: [null, "active", "backlog"],
|
||||
},
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"reflect-metadata": "^0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/reflect-metadata": "^0.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
@@ -36,27 +36,27 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.15.0",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/extension-blockquote": "2.10.4",
|
||||
"@tiptap/extension-character-count": "2.11.0",
|
||||
"@tiptap/extension-collaboration": "2.11.0",
|
||||
"@tiptap/extension-image": "2.11.0",
|
||||
"@tiptap/extension-list-item": "2.11.0",
|
||||
"@tiptap/extension-mention": "2.11.0",
|
||||
"@tiptap/extension-placeholder": "2.11.0",
|
||||
"@tiptap/extension-task-item": "2.11.0",
|
||||
"@tiptap/extension-task-list": "2.11.0",
|
||||
"@tiptap/extension-text-align": "2.11.0",
|
||||
"@tiptap/extension-text-style": "2.11.0",
|
||||
"@tiptap/extension-underline": "2.11.0",
|
||||
"@tiptap/html": "2.11.0",
|
||||
"@tiptap/pm": "2.11.0",
|
||||
"@tiptap/react": "2.11.0",
|
||||
"@tiptap/starter-kit": "2.11.0",
|
||||
"@tiptap/suggestion": "2.11.0",
|
||||
"@tiptap/extension-character-count": "2.10.4",
|
||||
"@tiptap/extension-collaboration": "2.10.4",
|
||||
"@tiptap/extension-image": "2.10.4",
|
||||
"@tiptap/extension-list-item": "2.10.4",
|
||||
"@tiptap/extension-mention": "2.10.4",
|
||||
"@tiptap/extension-placeholder": "2.10.4",
|
||||
"@tiptap/extension-task-item": "2.10.4",
|
||||
"@tiptap/extension-task-list": "2.10.4",
|
||||
"@tiptap/extension-text-align": "2.10.4",
|
||||
"@tiptap/extension-text-style": "2.10.4",
|
||||
"@tiptap/extension-underline": "2.10.4",
|
||||
"@tiptap/html": "2.10.4",
|
||||
"@tiptap/pm": "2.10.4",
|
||||
"@tiptap/react": "2.10.4",
|
||||
"@tiptap/starter-kit": "2.10.4",
|
||||
"@tiptap/suggestion": "2.10.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"jsx-dom-cjs": "^8.0.3",
|
||||
@@ -64,6 +64,7 @@
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-model": "^1.25.1",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
@@ -74,9 +75,9 @@
|
||||
"yjs": "^13.6.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EditorState, Selection } from "@tiptap/pm/state";
|
||||
import { Node as ProsemirrorNode } from "prosemirror-model";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
@@ -21,7 +22,10 @@ export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassNam
|
||||
);
|
||||
|
||||
// Helper function to find the parent node of a specific type
|
||||
export function findParentNodeOfType(selection: Selection, typeName: string) {
|
||||
export function findParentNodeOfType(selection: Selection, typeName: string): {
|
||||
node: ProsemirrorNode;
|
||||
pos: number;
|
||||
} | null {
|
||||
let depth = selection.$anchor.depth;
|
||||
while (depth > 0) {
|
||||
const node = selection.$anchor.node(depth);
|
||||
|
||||
@@ -14,21 +14,16 @@ export const setText = (editor: Editor, range?: Range) => {
|
||||
|
||||
export const toggleHeading = (editor: Editor, level: 1 | 2 | 3 | 4 | 5 | 6, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode(CORE_EXTENSIONS.HEADING, { level }).run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleHeading({ level }).run();
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleBold().run();
|
||||
};
|
||||
|
||||
export const toggleItalic = (editor: Editor, range?: Range) => {
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleItalic().run();
|
||||
};
|
||||
|
||||
@@ -67,16 +62,12 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleOrderedList().run();
|
||||
};
|
||||
|
||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleBulletList().run();
|
||||
};
|
||||
|
||||
@@ -86,9 +77,7 @@ export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
else editor.chain().focus().toggleStrike().run();
|
||||
};
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import {
|
||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
// editor schemas
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
/**
|
||||
@@ -56,7 +54,6 @@ export const convertBase64StringToBinaryData = (document: string): ArrayBuffer =
|
||||
*/
|
||||
export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(richTextEditorSchema, contentJSON, "default");
|
||||
@@ -72,7 +69,6 @@ export const getBinaryDataFromRichTextEditorHTMLString = (descriptionHTML: strin
|
||||
*/
|
||||
export const getBinaryDataFromDocumentEditorHTMLString = (descriptionHTML: string): Uint8Array => {
|
||||
// convert HTML to JSON
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
|
||||
@@ -101,7 +97,6 @@ export const getAllDocumentFormatsFromRichTextEditorBinaryData = (
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, richTextEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const contentHTML = generateHTML(contentJSON, RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
|
||||
return {
|
||||
@@ -131,7 +126,6 @@ export const getAllDocumentFormatsFromDocumentEditorBinaryData = (
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON();
|
||||
// convert to HTML
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
@@ -19,7 +19,8 @@
|
||||
"react": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"tsup": "8.4.0",
|
||||
|
||||
@@ -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,
|
||||
@@ -11,11 +11,11 @@
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/utils": "*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"intl-messageformat": "^10.7.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -15,7 +15,7 @@
|
||||
"winston-daily-rotate-file": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -24,9 +24,9 @@
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "^5.3.3"
|
||||
|
||||
@@ -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",
|
||||
@@ -9,7 +9,7 @@
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/constants": "*",
|
||||
"@plane/constants": "workspace:*",
|
||||
"axios": "^1.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -14,7 +14,7 @@
|
||||
"zod": "^3.22.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/tailwind-config",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "tailwind.config.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"types": "./src/index.d.ts",
|
||||
|
||||
Vendored
+8
-1
@@ -1,4 +1,4 @@
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
|
||||
import { TIssuePriorities } from "../issues";
|
||||
import { TIssueAttachment } from "./issue_attachment";
|
||||
import { TIssueLink } from "./issue_link";
|
||||
@@ -181,3 +181,10 @@ export type TPublicIssuesResponse = {
|
||||
extra_stats: null;
|
||||
results: TPublicIssueResponseResults;
|
||||
};
|
||||
|
||||
export interface IWorkItemPeekOverview {
|
||||
embedIssue?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
is_draft?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
}
|
||||
+2
-1
@@ -85,15 +85,16 @@ export interface IProjectMemberLite {
|
||||
export type TProjectMembership = {
|
||||
member: string;
|
||||
role: TUserPermissions | EUserProjectRoles;
|
||||
created_at: string;
|
||||
} & (
|
||||
| {
|
||||
id: string;
|
||||
original_role: EUserProjectRoles;
|
||||
created_at: string;
|
||||
}
|
||||
| {
|
||||
id: null;
|
||||
original_role: null;
|
||||
created_at: null;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -26,13 +26,15 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.10",
|
||||
"@blueprintjs/core": "^4.16.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/utils": "*",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -48,9 +50,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.4.0",
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.1.1",
|
||||
"@storybook/addon-interactions": "^8.1.1",
|
||||
"@storybook/addon-links": "^8.1.1",
|
||||
@@ -58,14 +60,14 @@
|
||||
"@storybook/addon-styling-webpack": "^1.0.0",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^1.0.2",
|
||||
"@storybook/blocks": "^8.1.1",
|
||||
"@storybook/react": "^8.1.1",
|
||||
"@storybook/react-webpack5": "^8.1.1",
|
||||
"@storybook/react": "^8.1.1",
|
||||
"@storybook/test": "^8.1.1",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.5.2",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-color": "^3.0.9",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@types/react": "^18.3.11",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"classnames": "^2.3.2",
|
||||
"postcss-cli": "^11.0.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ type TUseDropdownKeyDown = {
|
||||
export const useDropdownKeyDown: TUseDropdownKeyDown = (onOpen, onClose, isOpen, selectActiveItem?) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
|
||||
if (!isOpen) {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
|
||||
@@ -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,
|
||||
@@ -16,16 +16,21 @@
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"isomorphic-dompurify": "^2.16.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
|
||||
@@ -333,3 +333,59 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
|
||||
}
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 "";
|
||||
};
|
||||
Generated
+16263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
packages:
|
||||
- web
|
||||
- space
|
||||
- admin
|
||||
- live
|
||||
- packages/*
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@swc/core'
|
||||
- core-js
|
||||
- esbuild
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -40,7 +40,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
|
||||
const isButtonDisabled = email.length === 0 || Boolean(emailError?.email) || isSubmitting;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(true)
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
@@ -54,9 +54,12 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onFocus={() => {setIsFocused(true)}}
|
||||
onBlur={() => {setIsFocused(false)}}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
@@ -70,14 +73,18 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<XCircle
|
||||
className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs"
|
||||
{email.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear email"
|
||||
onClick={() => {
|
||||
setEmail("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XCircle className="h-[46px] w-11 px-3 stroke-custom-text-400 hover:cursor-pointer text-xs" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{emailError?.email && !isFocused && (
|
||||
@@ -92,4 +99,4 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -19,13 +19,13 @@
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/i18n": "*",
|
||||
"@plane/propel": "*",
|
||||
"@plane/services": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/editor": "workspace:*",
|
||||
"@plane/i18n": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"axios": "^1.8.3",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
@@ -51,9 +51,9 @@
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
"@plane/tailwind-config": "*",
|
||||
"@plane/typescript-config": "*",
|
||||
"@plane/eslint-config": "workspace:*",
|
||||
"@plane/tailwind-config": "workspace:*",
|
||||
"@plane/typescript-config": "workspace:*",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.1",
|
||||
"@types/node": "18.14.1",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"ui": "tui",
|
||||
"globalEnv": [
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_API_BASE_URL",
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SimpleEmptyState } from "@/components/empty-state";
|
||||
import { InboxContentRoot } from "@/components/inbox";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
import { NotificationsRoot } from "@/components/workspace-notifications";
|
||||
// hooks
|
||||
import { useIssueDetail, useUserPermissions, useWorkspace, useWorkspaceNotifications } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const WorkspaceDashboardPage = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
@@ -24,98 +16,15 @@ const WorkspaceDashboardPage = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
currentSelectedNotificationId,
|
||||
setCurrentSelectedNotificationId,
|
||||
notificationLiteByNotificationId,
|
||||
notificationIdsByWorkspaceId,
|
||||
getNotifications,
|
||||
} = useWorkspaceNotifications();
|
||||
const { fetchUserProjectInfo } = useUserPermissions();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t("notification.page_label", { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
|
||||
notificationLiteByNotificationId(currentSelectedNotificationId);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/intake/issue-detail" });
|
||||
|
||||
// fetching workspace work item properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
|
||||
// fetch workspace notifications
|
||||
const notificationMutation =
|
||||
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
|
||||
? ENotificationLoader.MUTATION_LOADER
|
||||
: ENotificationLoader.INIT_LOADER;
|
||||
const notificationLoader =
|
||||
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
|
||||
? ENotificationQueryParamType.CURRENT
|
||||
: ENotificationQueryParamType.INIT;
|
||||
useSWR(
|
||||
currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION` : null,
|
||||
currentWorkspace?.slug
|
||||
? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader)
|
||||
: null
|
||||
);
|
||||
|
||||
// fetching user project member info
|
||||
const { isLoading: projectMemberInfoLoader } = useSWR(
|
||||
workspace_slug && project_id && is_inbox_issue
|
||||
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
|
||||
: null,
|
||||
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
|
||||
);
|
||||
|
||||
const embedRemoveCurrentNotification = useCallback(
|
||||
() => setCurrentSelectedNotificationId(undefined),
|
||||
[setCurrentSelectedNotificationId]
|
||||
);
|
||||
|
||||
// clearing up the selected notifications when unmounting the page
|
||||
useEffect(
|
||||
() => () => {
|
||||
setCurrentSelectedNotificationId(undefined);
|
||||
setPeekIssue(undefined);
|
||||
},
|
||||
[setCurrentSelectedNotificationId, setPeekIssue]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full overflow-hidden overflow-y-auto">
|
||||
{!currentSelectedNotificationId ? (
|
||||
<div className="w-full h-screen flex justify-center items-center">
|
||||
<SimpleEmptyState title={t("notification.empty_state.detail.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
|
||||
<>
|
||||
{projectMemberInfoLoader ? (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<InboxContentRoot
|
||||
setIsMobileSidebar={() => {}}
|
||||
isMobileSidebar={false}
|
||||
workspaceSlug={workspace_slug}
|
||||
projectId={project_id}
|
||||
inboxIssueId={issue_id}
|
||||
isNotificationEmbed
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<IssuePeekOverview embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<NotificationsRoot workspaceSlug={workspaceSlug?.toString()} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ContentWrapper } from "@/components/core";
|
||||
import { SettingsContentLayout, SettingsHeader } from "@/components/settings";
|
||||
import { SettingsHeader } from "@/components/settings";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
|
||||
@@ -15,8 +15,8 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="px-4 md:pl-12 md:pt-page-y md:flex w-full">
|
||||
<SettingsContentLayout>{children}</SettingsContentLayout>
|
||||
<ContentWrapper className="px-4 md:pl-12 md:flex w-full">
|
||||
<div className="w-full h-full overflow-hidden">{children}</div>
|
||||
</ContentWrapper>
|
||||
</main>
|
||||
</WorkspaceAuthWrapper>
|
||||
|
||||
@@ -39,13 +39,13 @@ const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) =>
|
||||
hamburgerContent={WorkspaceSettingsSidebar}
|
||||
activePath={getWorkspaceActivePath(pathname) || ""}
|
||||
/>
|
||||
<div className="inset-y-0 flex flex-row w-full">
|
||||
<div className="inset-y-0 flex flex-row w-full h-full">
|
||||
{workspaceUserInfo && !isAuthorized ? (
|
||||
<NotAuthorizedView section="settings" className="h-auto" />
|
||||
) : (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{<WorkspaceSettingsSidebar />}</div>
|
||||
{children}
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,9 @@ const ProfileSettingsLayout = observer((props: Props) => {
|
||||
<div className="hidden md:block">
|
||||
<ProfileSidebar />
|
||||
</div>
|
||||
<SettingsContentWrapper>{children}</SettingsContentWrapper>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<SettingsContentWrapper>{children}</SettingsContentWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -36,7 +36,7 @@ const ProjectSettingsLayout = observer((props: Props) => {
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
{children}
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">{children}</div>
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
|
||||
@@ -47,17 +47,19 @@ const WORKSPACE_ACTION_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const ProjectActionIcons = ({ type, size, className }: { type: string; size?: number; className?: string }) => {
|
||||
const ProjectActionIcons = ({ type, size, className = "" }: { type: string; size?: number; className?: string }) => {
|
||||
const icons = {
|
||||
profile: CircleUser,
|
||||
security: KeyRound,
|
||||
activity: Activity,
|
||||
appearance: Settings2,
|
||||
preferences: Settings2,
|
||||
notifications: Bell,
|
||||
"api-tokens": KeyRound,
|
||||
};
|
||||
|
||||
if (type === undefined) return null;
|
||||
const Icon = icons[type as keyof typeof icons];
|
||||
if (!Icon) return null;
|
||||
return <Icon size={size} className={className} />;
|
||||
};
|
||||
export const ProfileLayoutSidebar = observer(() => {
|
||||
|
||||
@@ -39,6 +39,7 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
toggleDeleteIssueModal,
|
||||
isBulkDeleteIssueModalOpen,
|
||||
toggleBulkDeleteIssueModal,
|
||||
createWorkItemAllowedProjectIds,
|
||||
} = useCommandPalette();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId) : undefined;
|
||||
@@ -80,6 +81,7 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
|
||||
data={getCreateIssueModalData()}
|
||||
isDraft={isDraftIssue}
|
||||
onSubmit={handleCreateIssueSubmit}
|
||||
allowedProjectIds={createWorkItemAllowedProjectIds}
|
||||
/>
|
||||
{workspaceSlug && projectId && issueId && issueDetails && (
|
||||
<DeleteIssueModal
|
||||
|
||||
@@ -4,21 +4,29 @@ import { observer } from "mobx-react-lite";
|
||||
import { ISearchIssueResponse, TIssue } from "@plane/types";
|
||||
// components
|
||||
import { IssueModalContext } from "@/components/issues";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user/user-user";
|
||||
|
||||
export type TIssueModalProviderProps = {
|
||||
templateId?: string;
|
||||
dataForPreload?: Partial<TIssue>;
|
||||
allowedProjectIds?: string[];
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueModalProvider = observer((props: TIssueModalProviderProps) => {
|
||||
const { children } = props;
|
||||
const { children, allowedProjectIds } = props;
|
||||
// states
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
// store hooks
|
||||
const { projectsWithCreatePermissions } = useUser();
|
||||
// derived values
|
||||
const projectIdsWithCreatePermissions = Object.keys(projectsWithCreatePermissions ?? {});
|
||||
|
||||
return (
|
||||
<IssueModalContext.Provider
|
||||
value={{
|
||||
allowedProjectIds: allowedProjectIds ?? projectIdsWithCreatePermissions,
|
||||
workItemTemplateId: null,
|
||||
setWorkItemTemplateId: () => {},
|
||||
isApplyingTemplate: false,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./notification-card/root";
|
||||
export * from "./list-root";
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NotificationCardListRoot } from "./notification-card/root";
|
||||
|
||||
export type TNotificationListRoot = {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const NotificationListRoot = (props: TNotificationListRoot) => <NotificationCardListRoot {...props} />;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { IWorkItemPeekOverview } from "@plane/types";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
import { TPeekIssue } from "@/store/issue/issue-details/root.store";
|
||||
|
||||
export type TNotificationPreview = {
|
||||
isWorkItem: boolean;
|
||||
PeekOverviewComponent: React.ComponentType<IWorkItemPeekOverview>;
|
||||
setPeekWorkItem: (peekIssue: TPeekIssue | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function returns if the current active notification is related to work item or an epic.
|
||||
* @returns isWorkItem: boolean, peekOverviewComponent: IWorkItemPeekOverview, setPeekWorkItem
|
||||
*/
|
||||
export const useNotificationPreview = (): TNotificationPreview => {
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail(EIssueServiceType.ISSUES);
|
||||
|
||||
return {
|
||||
isWorkItem: Boolean(peekIssue),
|
||||
PeekOverviewComponent: IssuePeekOverview,
|
||||
setPeekWorkItem: setPeekIssue,
|
||||
};
|
||||
};
|
||||
@@ -55,7 +55,6 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
`relative flex items-center rounded-md bg-onboarding-background-200 border`,
|
||||
!isFocused && Boolean(emailError?.email) ? `border-red-500` : `border-onboarding-border-100`
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onFocus={() => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
@@ -84,6 +83,7 @@ export const AuthEmailForm: FC<TAuthEmailForm> = observer((props) => {
|
||||
}}
|
||||
className="absolute right-3 size-5 grid place-items-center"
|
||||
aria-label={t("aria_labels.auth_forms.clear_email")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<XCircle className="size-5 stroke-custom-text-400" />
|
||||
</button>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -14,8 +14,9 @@ import { DateRangeDropdown, ProjectDropdown } from "@/components/dropdowns";
|
||||
// constants
|
||||
// helpers
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user/user-user";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: Partial<ICycle>, dirtyFields: any) => Promise<void>;
|
||||
@@ -36,7 +37,10 @@ const defaultValues: Partial<ICycle> = {
|
||||
|
||||
export const CycleForm: React.FC<Props> = (props) => {
|
||||
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data, isMobile = false } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { projectsWithCreatePermissions } = useUser();
|
||||
// form data
|
||||
const {
|
||||
formState: { errors, isSubmitting, dirtyFields },
|
||||
@@ -75,12 +79,14 @@ export const CycleForm: React.FC<Props> = (props) => {
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
if (!Array.isArray(val)) {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
}
|
||||
}}
|
||||
multiple={false}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
renderCondition={(project) => !!projectsWithCreatePermissions?.[project.id]}
|
||||
tabIndex={getIndex("cover_image")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -23,6 +22,7 @@ import { Avatar, AvatarGroup, FavoriteStar, LayersIcon, Tooltip, TransferIcon, s
|
||||
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
|
||||
@@ -230,9 +230,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 +267,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
|
||||
</span>
|
||||
}
|
||||
mergeDates
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled
|
||||
hideIcon={{
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./widgets";
|
||||
export * from "./project-empty-state";
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useUserPermissions } from "@/hooks/store";
|
||||
// assets
|
||||
import ProjectEmptyStateImage from "@/public/empty-state/onboarding/dashboard-light.webp";
|
||||
|
||||
export const DashboardProjectEmptyState = observer(() => {
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full flex-col justify-center space-y-4 lg:w-3/5">
|
||||
<h4 className="text-xl font-semibold">Overview of your projects, activity, and metrics</h4>
|
||||
<p className="text-custom-text-300">
|
||||
Welcome to Plane, we are excited to have you here. Create your first project and track your work items, and this
|
||||
page will transform into a space that helps you progress. Admins will also see items which help their team
|
||||
progress.
|
||||
</p>
|
||||
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
|
||||
{canCreateProject && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setTrackElement("Project empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
Build your first project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card } from "@plane/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesErrorState,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
const WIDGET_KEY = "assigned_issues";
|
||||
|
||||
export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||
useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
expand: "issue_relation",
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
expand: "issue_relation",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{widgetStatsError ? (
|
||||
<IssuesErrorState
|
||||
isRefreshing={fetching}
|
||||
onClick={() =>
|
||||
handleUpdateFilters({
|
||||
duration: EDurationFilters.NONE,
|
||||
tab: "pending",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
widgetStats && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||
>
|
||||
Assigned to you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||
newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="assigned"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card } from "@plane/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesErrorState,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
const WIDGET_KEY = "created_issues";
|
||||
|
||||
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||
useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{widgetStatsError ? (
|
||||
<IssuesErrorState
|
||||
isRefreshing={fetching}
|
||||
onClick={() =>
|
||||
handleUpdateFilters({
|
||||
duration: EDurationFilters.NONE,
|
||||
tab: "pending",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
widgetStats && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||
>
|
||||
Created by you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||
newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import { DURATION_FILTER_OPTIONS, EDurationFilters } from "@plane/constants";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { DateFilterModal } from "@/components/core";
|
||||
// ui
|
||||
// helpers
|
||||
import { getDurationFilterDropdownLabel } from "@/helpers/dashboard.helper";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
customDates?: string[];
|
||||
onChange: (value: EDurationFilters, customDates?: string[]) => void;
|
||||
value: EDurationFilters;
|
||||
};
|
||||
|
||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||
const { customDates, onChange, value } = props;
|
||||
// states
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DateFilterModal
|
||||
isOpen={isDateFilterModalOpen}
|
||||
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||
onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)}
|
||||
title="Due date"
|
||||
/>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0"
|
||||
customButton={
|
||||
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
||||
{getDurationFilterDropdownLabel(value, customDates ?? [])}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
>
|
||||
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
if (option.key === "custom") setIsDateFilterModalOpen(true);
|
||||
else onChange(option.key);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./duration-filter";
|
||||
@@ -1,55 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import CompletedIssuesDark from "@/public/empty-state/dashboard/dark/completed-issues.svg";
|
||||
import OverdueIssuesDark from "@/public/empty-state/dashboard/dark/overdue-issues.svg";
|
||||
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
|
||||
import CompletedIssuesLight from "@/public/empty-state/dashboard/light/completed-issues.svg";
|
||||
import OverdueIssuesLight from "@/public/empty-state/dashboard/light/overdue-issues.svg";
|
||||
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
|
||||
|
||||
export const ASSIGNED_ISSUES_EMPTY_STATES = {
|
||||
pending: {
|
||||
title: "Work items assigned to you that are pending\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
upcoming: {
|
||||
title: "Upcoming work items assigned to\nyou will show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
overdue: {
|
||||
title: "Work items assigned to you that are past\ntheir due date will show up here.",
|
||||
darkImage: OverdueIssuesDark,
|
||||
lightImage: OverdueIssuesLight,
|
||||
},
|
||||
completed: {
|
||||
title: "Work items assigned to you that you have\nmarked Completed will show up here.",
|
||||
darkImage: CompletedIssuesDark,
|
||||
lightImage: CompletedIssuesLight,
|
||||
},
|
||||
};
|
||||
type Props = {
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
// TODO: update empty state logic to use a general component
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Assigned work items" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import CompletedIssuesDark from "@/public/empty-state/dashboard/dark/completed-issues.svg";
|
||||
import OverdueIssuesDark from "@/public/empty-state/dashboard/dark/overdue-issues.svg";
|
||||
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
|
||||
import CompletedIssuesLight from "@/public/empty-state/dashboard/light/completed-issues.svg";
|
||||
import OverdueIssuesLight from "@/public/empty-state/dashboard/light/overdue-issues.svg";
|
||||
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
|
||||
|
||||
export const CREATED_ISSUES_EMPTY_STATES = {
|
||||
pending: {
|
||||
title: "Work items created by you that are pending\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
upcoming: {
|
||||
title: "Upcoming work items you created\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
overdue: {
|
||||
title: "Work items created by you that are past their\ndue date will show up here.",
|
||||
darkImage: OverdueIssuesDark,
|
||||
lightImage: OverdueIssuesLight,
|
||||
},
|
||||
completed: {
|
||||
title: "Work items created by you that you have\nmarked completed will show up here.",
|
||||
darkImage: CompletedIssuesDark,
|
||||
lightImage: CompletedIssuesLight,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Assigned work items" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./issues-by-priority";
|
||||
export * from "./issues-by-state-group";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/issues-by-priority.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/issues-by-priority.svg";
|
||||
|
||||
export const IssuesByPriorityEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Work items assigned to you, broken down by
|
||||
<br />
|
||||
priority will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/issues-by-state-group.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/issues-by-state-group.svg";
|
||||
|
||||
export const IssuesByStateGroupEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Work items assigned to you, broken down by state,
|
||||
<br />
|
||||
will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/recent-activity.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/recent-activity.svg";
|
||||
|
||||
export const RecentActivityEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
All your work items activities across
|
||||
<br />
|
||||
projects will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage1 from "@/public/empty-state/dashboard/dark/recent-collaborators-1.svg";
|
||||
import DarkImage2 from "@/public/empty-state/dashboard/dark/recent-collaborators-2.svg";
|
||||
import DarkImage3 from "@/public/empty-state/dashboard/dark/recent-collaborators-3.svg";
|
||||
import LightImage1 from "@/public/empty-state/dashboard/light/recent-collaborators-1.svg";
|
||||
import LightImage2 from "@/public/empty-state/dashboard/light/recent-collaborators-2.svg";
|
||||
import LightImage3 from "@/public/empty-state/dashboard/light/recent-collaborators-3.svg";
|
||||
|
||||
export const RecentCollaboratorsEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image1 = resolvedTheme === "dark" ? DarkImage1 : LightImage1;
|
||||
const image2 = resolvedTheme === "dark" ? DarkImage2 : LightImage2;
|
||||
const image3 = resolvedTheme === "dark" ? DarkImage3 : LightImage3;
|
||||
|
||||
return (
|
||||
<div className="mt-7 mb-16 px-36 flex flex-col lg:flex-row items-center justify-between gap-x-24 gap-y-16">
|
||||
<p className="text-sm font-medium text-custom-text-300 lg:w-2/5 flex-shrink-0 text-center lg:text-left">
|
||||
Compare your activities with the top
|
||||
<br />
|
||||
seven in your project.
|
||||
</p>
|
||||
<div className="flex items-center justify-evenly gap-20 lg:w-3/5 flex-shrink-0">
|
||||
<div className="h-24 w-24 flex-shrink-0">
|
||||
<Image src={image1} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
<div className="h-24 w-24 flex-shrink-0">
|
||||
<Image src={image2} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
<div className="h-24 w-24 flex-shrink-0 hidden xl:block">
|
||||
<Image src={image3} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./issues";
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RefreshCcw } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isRefreshing: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const IssuesErrorState: React.FC<Props> = (props) => {
|
||||
const { isRefreshing, onClick } = props;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="h-24 w-24 bg-red-500/20 rounded-full grid place-items-center mx-auto">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500" />
|
||||
</div>
|
||||
<p className="mt-7 text-custom-text-300 text-sm font-medium">There was an error in fetching widget details</p>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<RefreshCcw className="h-3 w-3" />}
|
||||
className="mt-2 mx-auto"
|
||||
onClick={onClick}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? "Retrying" : "Retry"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
export * from "./dropdowns";
|
||||
export * from "./empty-states";
|
||||
export * from "./error-states";
|
||||
export * from "./issue-panels";
|
||||
export * from "./loaders";
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./overview-stats";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
export * from "./recent-projects";
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./issue-list-item";
|
||||
export * from "./issues-list";
|
||||
export * from "./tabs-list";
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { isToday } from "date-fns/isToday";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TIssue, TWidgetIssue } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
export type IssueListItemProps = {
|
||||
issueId: string;
|
||||
onClick: (issue: TIssue) => void;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const targetDate = getDate(issueDetails.target_date);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: blockedByIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
projectIdentifier={blockedByIssueProjectDetails?.identifier}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
issueSequenceId={blockedByIssues[0]?.sequence_id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const dueBy = findTotalDaysInRange(getDate(issueDetails.target_date), new Date(), false) ?? 0;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: blockedByIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={blockedByIssues[0]?.id}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-11 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const targetDate = getDate(issue.target_date);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const dueBy: number = findTotalDaysInRange(getDate(issue.target_date), new Date(), false) ?? 0;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-9 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { Loader, getButtonStyling } from "@plane/ui";
|
||||
import {
|
||||
AssignedCompletedIssueListItem,
|
||||
AssignedIssuesEmptyState,
|
||||
AssignedOverdueIssueListItem,
|
||||
AssignedUpcomingIssueListItem,
|
||||
CreatedCompletedIssueListItem,
|
||||
CreatedIssuesEmptyState,
|
||||
CreatedOverdueIssueListItem,
|
||||
CreatedUpcomingIssueListItem,
|
||||
IssueListItemProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
// ui
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getRedirectionFilters } from "@/helpers/dashboard.helper";
|
||||
// hooks
|
||||
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
export type WidgetIssuesListProps = {
|
||||
isLoading: boolean;
|
||||
tab: TIssuesListTypes;
|
||||
type: "assigned" | "created";
|
||||
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
|
||||
// handlers
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
|
||||
const filterParams = getRedirectionFilters(tab);
|
||||
|
||||
const ISSUE_LIST_ITEM: {
|
||||
[key: string]: {
|
||||
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
|
||||
};
|
||||
} = {
|
||||
assigned: {
|
||||
pending: AssignedUpcomingIssueListItem,
|
||||
upcoming: AssignedUpcomingIssueListItem,
|
||||
overdue: AssignedOverdueIssueListItem,
|
||||
completed: AssignedCompletedIssueListItem,
|
||||
},
|
||||
created: {
|
||||
pending: CreatedUpcomingIssueListItem,
|
||||
upcoming: CreatedUpcomingIssueListItem,
|
||||
overdue: CreatedOverdueIssueListItem,
|
||||
completed: CreatedCompletedIssueListItem,
|
||||
},
|
||||
};
|
||||
|
||||
const issuesList = widgetStats.issues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<Loader className="space-y-4 mt-7">
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
</Loader>
|
||||
) : issuesList.length > 0 ? (
|
||||
<>
|
||||
<div className="mt-7 border-b-[0.5px] border-custom-border-200 grid grid-cols-12 gap-1 text-xs text-custom-text-300 pb-1">
|
||||
<h6
|
||||
className={cn("pl-1 flex items-center gap-1 col-span-7", {
|
||||
"col-span-11": type === "assigned" && tab === "completed",
|
||||
"col-span-9": type === "created" && tab === "completed",
|
||||
})}
|
||||
>
|
||||
Work items
|
||||
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-2 flex items-center text-center justify-center">
|
||||
{widgetStats.count}
|
||||
</span>
|
||||
</h6>
|
||||
<h6 className="text-center col-span-1">Priority</h6>
|
||||
{["upcoming", "pending"].includes(tab) && <h6 className="text-center col-span-2">Due date</h6>}
|
||||
{tab === "overdue" && <h6 className="text-center col-span-2">Due by</h6>}
|
||||
{type === "assigned" && tab !== "completed" && <h6 className="text-center col-span-2">Blocked by</h6>}
|
||||
{type === "created" && <h6 className="text-center col-span-2">Assigned to</h6>}
|
||||
</div>
|
||||
<div className="pb-3 mt-2">
|
||||
{issuesList.map((issue) => {
|
||||
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
||||
|
||||
if (!IssueListItem) return null;
|
||||
|
||||
return (
|
||||
<IssueListItem
|
||||
key={issue.id}
|
||||
issueId={issue.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
onClick={handleIssuePeekOverview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full grid place-items-center my-6">
|
||||
{type === "assigned" && <AssignedIssuesEmptyState type={tab} />}
|
||||
{type === "created" && <CreatedIssuesEmptyState type={tab} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && issuesList.length > 0 && (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||
className={cn(
|
||||
getButtonStyling("link-primary", "sm"),
|
||||
"w-min my-3 mx-auto py-1 px-2 text-xs hover:bg-custom-primary-100/20"
|
||||
)}
|
||||
>
|
||||
View all work items
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// helpers
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
durationFilter: EDurationFilters;
|
||||
selectedTab: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const TabsList: React.FC<Props> = observer((props) => {
|
||||
const { durationFilter, selectedTab } = props;
|
||||
|
||||
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
return (
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
|
||||
{
|
||||
// right shadow
|
||||
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
|
||||
// left shadow
|
||||
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
height: "calc(100% - 2px)",
|
||||
width: `calc(${100 / tabsList.length}% - 1px)`,
|
||||
transform: `translate(${selectedTabIndex * 100}%, -50%)`,
|
||||
}}
|
||||
/>
|
||||
{tabsList.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-100": selectedTab === tab.key,
|
||||
"hover:text-custom-text-300": selectedTab !== tab.key,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="scale-110">{tab.label}</span>
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const AssignedIssuesWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 p-6 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-7">
|
||||
<Loader.Item height="29px" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-11 space-y-10">
|
||||
<Loader.Item height="11px" width="35%" />
|
||||
<Loader.Item height="11px" width="45%" />
|
||||
<Loader.Item height="11px" width="55%" />
|
||||
<Loader.Item height="11px" width="40%" />
|
||||
<Loader.Item height="11px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./loader";
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByPriorityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center gap-1 h-full">
|
||||
<Loader.Item height="119px" width="14%" />
|
||||
<Loader.Item height="119px" width="26%" />
|
||||
<Loader.Item height="119px" width="36%" />
|
||||
<Loader.Item height="119px" width="18%" />
|
||||
<Loader.Item height="119px" width="6%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByStateGroupWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center justify-between gap-32 mt-12 pl-6">
|
||||
<div className="w-1/2 grid place-items-center">
|
||||
<div className="rounded-full overflow-hidden relative flex-shrink-0 h-[184px] w-[184px]">
|
||||
<Loader.Item height="184px" width="184px" />
|
||||
<div className="absolute h-[100px] w-[100px] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-custom-background-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-7 flex-shrink-0">
|
||||
{range(5).map((index) => (
|
||||
<Loader.Item key={index} height="11px" width="100%" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
// components
|
||||
import { TWidgetKeys } from "@plane/types";
|
||||
import { AssignedIssuesWidgetLoader } from "./assigned-issues";
|
||||
import { IssuesByPriorityWidgetLoader } from "./issues-by-priority";
|
||||
import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group";
|
||||
import { OverviewStatsWidgetLoader } from "./overview-stats";
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators";
|
||||
import { RecentProjectsWidgetLoader } from "./recent-projects";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
widgetKey: TWidgetKeys;
|
||||
};
|
||||
|
||||
export const WidgetLoader: React.FC<Props> = (props) => {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
overview_stats: <OverviewStatsWidgetLoader />,
|
||||
assigned_issues: <AssignedIssuesWidgetLoader />,
|
||||
created_issues: <AssignedIssuesWidgetLoader />,
|
||||
issues_by_state_groups: <IssuesByStateGroupWidgetLoader />,
|
||||
issues_by_priority: <IssuesByPriorityWidgetLoader />,
|
||||
recent_activity: <RecentActivityWidgetLoader />,
|
||||
recent_projects: <RecentProjectsWidgetLoader />,
|
||||
recent_collaborators: <RecentCollaboratorsWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const OverviewStatsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl py-6 grid grid-cols-4 gap-36 px-12">
|
||||
{range(4).map((index) => (
|
||||
<div key={index} className="space-y-3">
|
||||
<Loader.Item height="11px" width="50%" />
|
||||
<Loader.Item height="15px" />
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{range(7).map((index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="16px" width="16px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
<Loader.Item height="11px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentCollaboratorsWidgetLoader = () => (
|
||||
<>
|
||||
{range(8).map((index) => (
|
||||
<Loader key={index} className="bg-custom-background-100 rounded-xl px-6 pb-12">
|
||||
<div className="space-y-11 flex flex-col items-center">
|
||||
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
||||
<Loader.Item height="69px" width="69px" />
|
||||
</div>
|
||||
<Loader.Item height="11px" width="70%" />
|
||||
</div>
|
||||
</Loader>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentProjectsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{range(5).map((index) => (
|
||||
<div key={index} className="flex items-center gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="60px" width="60px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="17px" width="42%" />
|
||||
<Loader.Item height="23px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { TOverviewStatsWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { WidgetLoader } from "@/components/dashboard/widgets";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
|
||||
export type WidgetProps = {
|
||||
dashboardId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "overview_stats";
|
||||
|
||||
export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = getWidgetStats<TOverviewStatsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
|
||||
const today = renderFormattedPayloadDate(new Date());
|
||||
const STATS_LIST = [
|
||||
{
|
||||
key: "assigned",
|
||||
title: "Work items assigned",
|
||||
count: widgetStats?.assigned_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned`,
|
||||
},
|
||||
{
|
||||
key: "overdue",
|
||||
title: "Work items overdue",
|
||||
count: widgetStats?.pending_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`,
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
title: "Work items created",
|
||||
count: widgetStats?.created_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/created`,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
title: "Work items completed",
|
||||
count: widgetStats?.completed_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card
|
||||
spacing={ECardSpacing.SM}
|
||||
className="flex-row grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 space-y-0 p-0.5
|
||||
[&>div>a>div]:border-r
|
||||
[&>div:last-child>a>div]:border-0
|
||||
[&>div>a>div]:border-custom-border-200
|
||||
[&>div:nth-child(2)>a>div]:border-0
|
||||
[&>div:nth-child(2)>a>div]:lg:border-r
|
||||
"
|
||||
>
|
||||
{STATS_LIST.map((stat, index) => (
|
||||
<div
|
||||
key={stat.key}
|
||||
className={cn(
|
||||
`w-full flex flex-col gap-2 hover:bg-custom-background-80`,
|
||||
index === 0 ? "rounded-l-md" : "",
|
||||
index === STATS_LIST.length - 1 ? "rounded-r-md" : "",
|
||||
index === 1 ? "rounded-tr-xl lg:rounded-[0px]" : "",
|
||||
index == 2 ? "rounded-bl-xl lg:rounded-[0px]" : ""
|
||||
)}
|
||||
>
|
||||
<Link href={stat.link} className="py-4 duration-300 rounded-[10px] w-full ">
|
||||
<div className={`relative flex pl-10 sm:pl-20 md:pl-20 lg:pl-20 items-center`}>
|
||||
<div>
|
||||
<h5 className="font-semibold text-xl">{stat.count}</h5>
|
||||
<p className="text-custom-text-300 text-sm xl:text-base">{stat.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { History } from "lucide-react";
|
||||
// types
|
||||
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||
// components
|
||||
import { Card, Avatar, getButtonStyling } from "@plane/ui";
|
||||
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
|
||||
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useDashboard, useUser } from "@/hooks/store";
|
||||
|
||||
const WIDGET_KEY = "recent_activity";
|
||||
|
||||
export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`;
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link href={redirectionLink} className="text-lg font-semibold text-custom-text-300 hover:underline mb-4">
|
||||
Your work item activities
|
||||
</Link>
|
||||
{widgetStats.length > 0 ? (
|
||||
<div className="mt-4 space-y-6">
|
||||
{widgetStats.map((activity) => (
|
||||
<div key={activity.id} className="flex gap-5">
|
||||
<div className="flex-shrink-0">
|
||||
{activity.field ? (
|
||||
activity.new_value === "restore" ? (
|
||||
<History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
) : (
|
||||
<div className="flex h-6 w-6 justify-center">
|
||||
<ActivityIcon activity={activity} />
|
||||
</div>
|
||||
)
|
||||
) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? (
|
||||
<Avatar
|
||||
src={getFileURL(activity.actor_detail.avatar_url)}
|
||||
name={activity.actor_detail.display_name}
|
||||
size={24}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white">
|
||||
{activity.actor_detail.is_bot
|
||||
? activity.actor_detail.first_name.charAt(0)
|
||||
: activity.actor_detail.display_name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-2 break-words">
|
||||
<p className="inline text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "}
|
||||
</span>
|
||||
{activity.field ? (
|
||||
<ActivityMessage activity={activity} showIssue />
|
||||
) : (
|
||||
<span>
|
||||
created <IssueLink activity={activity} />
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-custom-text-200 whitespace-nowrap">
|
||||
{calculateTimeAgo(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Link
|
||||
href={redirectionLink}
|
||||
className={cn(
|
||||
getButtonStyling("link-primary", "sm"),
|
||||
"mx-auto w-min px-2 py-1 text-xs hover:bg-custom-primary-100/20"
|
||||
)}
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center">
|
||||
<RecentActivityEmptyState />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useDashboard, useMember, useUser } from "@/hooks/store";
|
||||
// components
|
||||
import { WidgetLoader } from "../loaders";
|
||||
|
||||
type CollaboratorListItemProps = {
|
||||
issueCount: number;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
||||
const { issueCount, userId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const userDetails = getUserDetails(userId);
|
||||
const isCurrentUser = userId === currentUser?.id;
|
||||
|
||||
if (!userDetails || userDetails.is_bot) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
||||
<div className="flex justify-center">
|
||||
<Avatar
|
||||
src={getFileURL(userDetails.avatar_url)}
|
||||
name={userDetails.display_name}
|
||||
size={69}
|
||||
className="!text-3xl !font-medium"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<h6 className="mt-6 truncate text-xs font-semibold group-hover:underline">
|
||||
{isCurrentUser ? "You" : userDetails?.display_name}
|
||||
</h6>
|
||||
<p className="mt-2 text-sm">
|
||||
{issueCount} active work items{issueCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type CollaboratorsListProps = {
|
||||
dashboardId: string;
|
||||
searchQuery?: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "recent_collaborators";
|
||||
|
||||
export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
|
||||
const { dashboardId, searchQuery = "", workspaceSlug } = props;
|
||||
|
||||
// state
|
||||
const [visibleItems, setVisibleItems] = useState(16);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats } = useDashboard();
|
||||
const { getUserDetails } = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { data: widgetStats } = useSWR(
|
||||
workspaceSlug && dashboardId ? `WIDGET_STATS_${workspaceSlug}_${dashboardId}` : null,
|
||||
workspaceSlug && dashboardId
|
||||
? () =>
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
})
|
||||
: null
|
||||
) as {
|
||||
data: TRecentCollaboratorsWidgetResponse[] | undefined;
|
||||
};
|
||||
|
||||
if (!widgetStats)
|
||||
return (
|
||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||
<WidgetLoader widgetKey={WIDGET_KEY} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]);
|
||||
|
||||
const filteredStats = sortedStats.filter((user) => {
|
||||
if (!user) return false;
|
||||
const userDetails = getUserDetails(user?.user_id);
|
||||
if (!userDetails || userDetails.is_bot) return false;
|
||||
const { display_name, first_name, last_name } = userDetails;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
display_name?.toLowerCase().includes(searchLower) ||
|
||||
first_name?.toLowerCase().includes(searchLower) ||
|
||||
last_name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Update the displayedStats to always use the visibleItems limit
|
||||
const handleLoadMore = () => {
|
||||
setVisibleItems((prev) => {
|
||||
const newValue = prev + 16;
|
||||
if (newValue >= filteredStats.length) {
|
||||
setIsExpanded(true);
|
||||
return filteredStats.length;
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
setVisibleItems(16);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const displayedStats = filteredStats.slice(0, visibleItems);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||
{displayedStats?.map((user) => (
|
||||
<CollaboratorListItem
|
||||
key={user?.user_id}
|
||||
issueCount={user?.active_issue_count}
|
||||
userId={user?.user_id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredStats.length > visibleItems && !isExpanded && (
|
||||
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleLoadMore}>
|
||||
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">
|
||||
Load more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleHide}>
|
||||
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">Hide</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./root";
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
// types
|
||||
import { Card } from "@plane/ui";
|
||||
import { WidgetProps } from "@/components/dashboard/widgets";
|
||||
// components
|
||||
import { CollaboratorsList } from "./collaborators-list";
|
||||
|
||||
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col sm:flex-row items-start justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
|
||||
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
||||
View and find all members you collaborate with across projects
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 flex min-w-full md:min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
className="w-full border-none bg-transparent text-sm focus:outline-none"
|
||||
placeholder="Search for collaborators"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CollaboratorsList dashboardId={dashboardId} searchQuery={searchQuery} workspaceSlug={workspaceSlug} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane types
|
||||
import { PROJECT_BACKGROUND_COLORS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TRecentProjectsWidgetResponse } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, AvatarGroup, Card } from "@plane/ui";
|
||||
// components
|
||||
import { Logo } from "@/components/common";
|
||||
import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
|
||||
// constants
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useDashboard,
|
||||
useProject,
|
||||
useCommandPalette,
|
||||
useUserPermissions,
|
||||
useMember,
|
||||
} from "@/hooks/store";
|
||||
// plane web constants
|
||||
|
||||
const WIDGET_KEY = "recent_projects";
|
||||
|
||||
type ProjectListItemProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
|
||||
const { projectId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
|
||||
<div
|
||||
className={`grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-transparent ${randomBgColor}`}
|
||||
>
|
||||
<div className="grid h-7 w-7 place-items-center">
|
||||
<Logo logo={projectDetails.logo_props} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow truncate">
|
||||
<h6 className="truncate text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
|
||||
{projectDetails.name}
|
||||
</h6>
|
||||
<div className="mt-2">
|
||||
<AvatarGroup>
|
||||
{projectDetails.members?.map((memberId) => {
|
||||
const userDetails = getUserDetails(memberId);
|
||||
if (!userDetails) return null;
|
||||
return (
|
||||
<Avatar key={userDetails.id} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = getWidgetStats<TRecentProjectsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline mb-4"
|
||||
>
|
||||
Recent projects
|
||||
</Link>
|
||||
<div className="mt-4 space-y-8">
|
||||
{canCreateProject && (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Sidebar");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-dashed border-custom-primary-60 bg-custom-primary-100/20 text-custom-primary-100">
|
||||
<Plus className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
|
||||
Create new project
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
{widgetStats.map((projectId) => (
|
||||
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { DateRange, Matcher } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -17,6 +17,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { useDropdown } from "@/hooks/use-dropdown";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
import { MergedDateDisplay } from "./merged-date";
|
||||
// types
|
||||
import { TButtonVariants } from "./types";
|
||||
|
||||
@@ -30,11 +31,14 @@ type Props = {
|
||||
buttonVariant: TButtonVariants;
|
||||
cancelButtonText?: string;
|
||||
className?: string;
|
||||
clearIconClassName?: string;
|
||||
disabled?: boolean;
|
||||
hideIcon?: {
|
||||
from?: boolean;
|
||||
to?: boolean;
|
||||
};
|
||||
isClearable?: boolean;
|
||||
mergeDates?: boolean;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
onSelect?: (range: DateRange | undefined) => void;
|
||||
@@ -65,11 +69,14 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
buttonToDateClassName,
|
||||
buttonVariant,
|
||||
className,
|
||||
clearIconClassName = "",
|
||||
disabled = false,
|
||||
hideIcon = {
|
||||
from: true,
|
||||
to: true,
|
||||
},
|
||||
isClearable = false,
|
||||
mergeDates,
|
||||
minDate,
|
||||
maxDate,
|
||||
onSelect,
|
||||
@@ -118,20 +125,18 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
setIsOpen,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
setDateRange({
|
||||
from: value.from,
|
||||
to: value.to,
|
||||
});
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
const disabledDays: Matcher[] = [];
|
||||
if (minDate) disabledDays.push({ before: minDate });
|
||||
if (maxDate) disabledDays.push({ after: maxDate });
|
||||
|
||||
const clearDates = () => {
|
||||
const clearedRange = { from: undefined, to: undefined };
|
||||
setDateRange(clearedRange);
|
||||
onSelect?.(clearedRange);
|
||||
};
|
||||
|
||||
const hasDisplayedDates = dateRange.from || dateRange.to;
|
||||
|
||||
useEffect(() => {
|
||||
setDateRange(value);
|
||||
}, [value]);
|
||||
@@ -158,9 +163,9 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
tooltipContent={
|
||||
customTooltipContent ?? (
|
||||
<>
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
|
||||
{" - "}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : ""}
|
||||
{dateRange.from && dateRange.to ? " - " : ""}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -168,19 +173,70 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
variant={buttonVariant}
|
||||
renderToolTipByDefault={renderByDefault}
|
||||
>
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonFromDateClassName)}
|
||||
>
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonToDateClassName)}
|
||||
>
|
||||
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
|
||||
</span>
|
||||
{mergeDates ? (
|
||||
// Merged date display
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from || dateRange.to ? (
|
||||
<MergedDateDisplay
|
||||
startDate={dateRange.from}
|
||||
endDate={dateRange.to}
|
||||
className="flex-grow truncate text-xs"
|
||||
/>
|
||||
) : (
|
||||
renderPlaceholder && (
|
||||
<>
|
||||
<span className="text-custom-text-400">{placeholder.from}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-custom-text-400">{placeholder.to}</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{isClearable && !disabled && hasDisplayedDates && (
|
||||
<X
|
||||
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer", clearIconClassName)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearDates();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Original separate date display
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
|
||||
buttonFromDateClassName
|
||||
)}
|
||||
>
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
|
||||
buttonToDateClassName
|
||||
)}
|
||||
>
|
||||
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
|
||||
</span>
|
||||
{isClearable && !disabled && hasDisplayedDates && (
|
||||
<X
|
||||
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer ml-1", clearIconClassName)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearDates();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./cycle";
|
||||
export * from "./date-range";
|
||||
export * from "./date";
|
||||
export * from "./estimate";
|
||||
export * from "./merged-date";
|
||||
export * from "./module";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { formatDateRange } from "@plane/utils";
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
startDate: Date | string | null | undefined;
|
||||
endDate: Date | string | null | undefined;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats merged date range display with smart formatting
|
||||
* - Single date: "Jan 24, 2025"
|
||||
* - Same year, same month: "Jan 24 - 28, 2025"
|
||||
* - Same year, different month: "Jan 24 - Feb 6, 2025"
|
||||
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
|
||||
*/
|
||||
export const MergedDateDisplay: React.FC<Props> = observer((props) => {
|
||||
const { startDate, endDate, className = "" } = props;
|
||||
|
||||
// Parse dates
|
||||
const parsedStartDate = getDate(startDate);
|
||||
const parsedEndDate = getDate(endDate);
|
||||
|
||||
const displayText = formatDateRange(parsedStartDate, parsedEndDate);
|
||||
|
||||
if (!displayText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span className={className}>{displayText}</span>;
|
||||
});
|
||||
@@ -46,10 +46,7 @@ export const useSubIssueOperations = (issueServiceType: TIssueServiceType): TSub
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
message: t("entity.link_copied_to_clipboard", {
|
||||
entity:
|
||||
issueServiceType === EIssueServiceType.ISSUES
|
||||
? t("issue.label", { count: 1 })
|
||||
: t("epic.label", { count: 1 }),
|
||||
entity: t("epic.label", { count: 1 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
+42
-24
@@ -1,11 +1,10 @@
|
||||
// plane imports
|
||||
import { SyntheticEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
// components
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns";
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns";
|
||||
// hooks
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
@@ -37,6 +36,22 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleStartDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
start_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
target_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!displayProperties) return <></>;
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
@@ -88,29 +103,32 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue.target_date ?? null}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(
|
||||
workspaceSlug,
|
||||
issue.project_id,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
{
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
},
|
||||
{ ...issue }
|
||||
)
|
||||
}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-30"
|
||||
{/* merged dates */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateRangeDropdown
|
||||
value={{
|
||||
from: getDate(issue.start_date) || undefined,
|
||||
to: getDate(issue.target_date) || undefined,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
handleStartDate(range?.from ?? null);
|
||||
handleTargetDate(range?.to ?? null);
|
||||
}}
|
||||
hideIcon={{
|
||||
from: false,
|
||||
}}
|
||||
isClearable
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
disabled={!disabled}
|
||||
showTooltip
|
||||
customTooltipHeading="Date Range"
|
||||
renderPlaceholder={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user