Compare commits

..
Author SHA1 Message Date
pablohashescobar 4cc1b79d81 chore: instance tracing 2024-10-04 21:35:13 +05:30
sriram veeraghanta 4a6f646317 fix: lockfile update 2024-10-04 19:38:19 +05:30
sriram veeraghanta b8e21d92bf Merge branch 'preview' of github.com:makeplane/plane into preview 2024-10-04 19:26:06 +05:30
sriram veeraghanta b87d5c5be6 fix: version upgrade 2024-10-04 19:25:49 +05:30
ach5948andGitHub ceda06e88d fix: Remove typo from Contributing doc (#5736) 2024-10-04 19:24:47 +05:30
sriram veeraghanta eb344881c2 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-10-04 19:22:26 +05:30
01257a6936 chore: permission layer and updated issues v1 query from workspace to project level (#5753)
Co-authored-by: gurusainath <gurusainath007@gmail.com>
2024-10-04 18:34:46 +05:30
Prateek ShouryaandGitHub 51b01ebcac [WEB-2580] chore: improvements for custom search select. (#5744)
* [WEB-2580] chore: improvements for custom search select.

* chore: update optionTooltip prop.

* chore: update option tooltip prop.

* chore: minor updates.
2024-10-04 17:31:09 +05:30
sriram veeraghanta 0a8d66dcc3 fix: trace information setup 2024-10-04 16:40:33 +05:30
Akshita GoyalandGitHub ec22f1fc53 fix: cycles build issue (#5750) 2024-10-04 14:11:26 +05:30
sriram veeraghanta a5e3e4fe7d fix: api tracing 2024-10-04 01:14:29 +05:30
Akshita GoyalandGitHub f1a0a8d925 Fix: Cycle graphs refactor (#5745)
* fix: community changes for cycle graphs

* fix: added dependency from root package.json
2024-10-03 19:25:53 +05:30
MihirandGitHub ee0dce46de [WEB-2520] fix-Sorted Icon Not Updating Dynamically in Spreadsheet View (#5688)
* Updated conditional rendering of sorting icons

* Removed unused imports
2024-10-03 17:31:08 +05:30
Ketan SharmaandGitHub b7ee7e19fc [WEB-2213] fix: group by persistence for list view (#5590)
* fix kanban view localStorage

* add functionality for list view and add type for kanban function

* add comment in issue-filter-helper store

* improved code quality

* add comment for clarity

* use better variable names

* use useCallback hook and change variable name

* made suggested changes
2024-10-03 17:29:50 +05:30
rahulrameshaandGitHub 8291043704 Stop duplicate issue layout updates (#5743) 2024-10-03 16:51:18 +05:30
Akshat JainandGitHub bc41b1113a add /live path in proxy pass (#5742) 2024-10-03 15:09:13 +05:30
DanciaandGitHub 77d4a8379d Updated SECURITY.md (#5737)
* Updated SECUTITY.md

* Updated SECUTITY.md

* minor fix
2024-10-03 14:09:01 +05:30
Prateek ShouryaandGitHub c90df623de fix: live base server url. (#5734)
* fix: live base server url.

* chore: update websocket URL logic.
2024-10-03 14:06:03 +05:30
Prateek ShouryaandGitHub 62c45f3bb1 [WEB-2559] fix: live server URL generation for self-managed instances. (#5733) 2024-10-01 21:03:17 +05:30
Prateek ShouryaandGitHub 96dc9db237 [WEB-2559] fix: web socket protocol. (#5731) 2024-10-01 19:57:17 +05:30
Akshita GoyalandGitHub 5474ab326d fix: cycles import issues for ee (#5732) 2024-10-01 19:52:52 +05:30
Akshita GoyalandGitHub 4940dc2193 Chore: progress chart changes (#5707)
* fix: progress chart code splitting

* fix: progress chart code splitting

* fix: build errors + review changes
2024-10-01 18:59:49 +05:30
Satish GandhamandGitHub 632282d0df Fix build erorrs and unnecessary console.logs (#5730) 2024-10-01 15:31:04 +05:30
Satish GandhamandGitHub 33f6c1fe9e [WEB-2001] feat: Fix local cache issues r4 (#5726)
* - Handle single quotes in load workspace queries
- Add IS null where condition in query utils

* Fix description_html being lost

* Change secondary order to sequence_id

* Fix update persistence layer

* Add instrumentation

* - Fallback to server incase of any error
2024-10-01 14:18:01 +05:30
Prateek ShouryaandGitHub 927d265209 [WEB-2573] improvement: search-issues API optimization. (#5727)
* limit search results to 100 issues.
2024-10-01 14:15:35 +05:30
93 changed files with 3261 additions and 2350 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Thank you for showing an interest in contributing to Plane! All kinds of contrib
## Submitting an issue
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation.
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new information.
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
+30 -35
View File
@@ -1,44 +1,39 @@
# Security Policy
# Security policy
This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the communitys role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users.
This document outlines security procedures and vulnerabilities reporting for the Plane project.
## Reporting a vulnerability
If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so).
Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system.
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
To ensure a responsible and effective disclosure process, please adhere to the following:
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue.
- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data.
- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing.
## Out of Scope Vulnerabilities
## Out of scope
While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope:
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a users device.
- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS.
- Issues related to email spoofing.
- Missing DNSSEC, CAA, or CSP headers.
- Absence of secure or HTTP-only flags on non-sensitive cookies.
- Attacks requiring MITM or physical access to a user's device.
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
- Email spoofing.
- Missing DNSSEC, CAA, CSP headers.
- Lack of Secure or HTTP only flag on non-sensitive cookies.
## Our commitment
## Reporting Process
At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us:
If you discover a vulnerability, please adhere to the following reporting process:
- **Response Time** <br/>
We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution.
- **Legal Protection** <br/>
We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality** <br/>
Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent.
- **Recognition** <br/>
With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved.
- **Timely Resolution** <br/>
We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved.
1. Email your findings to security@plane.so.
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
## Our Commitment
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
reference: https://supabase.com/.well-known/security.txt
We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.22.0"
"version": "0.23.0"
}
+1 -1
View File
@@ -42,7 +42,7 @@ urlpatterns = [
),
# updated v2 paginated issues
path(
"workspaces/<str:slug>/v2/issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
+20 -13
View File
@@ -741,17 +741,12 @@ class DeletedIssuesListViewSet(BaseAPIView):
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
# getting the project_id from the request params
project_id = self.request.GET.get("project_id", None)
project_id = self.kwargs.get("project_id")
issue_queryset = Issue.issue_objects.filter(
workspace__slug=workspace_slug
workspace__slug=workspace_slug, project_id=project_id
)
if project_id:
issue_queryset = issue_queryset.filter(project_id=project_id)
return (
issue_queryset.select_related(
"workspace", "project", "state", "parent"
@@ -793,8 +788,8 @@ class IssuePaginatedViewSet(BaseViewSet):
return paginated_data
def list(self, request, slug):
project_id = self.request.GET.get("project_id", None)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gt", None)
@@ -833,14 +828,26 @@ class IssuePaginatedViewSet(BaseViewSet):
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(workspace__slug=slug)
if project_id:
base_queryset = base_queryset.filter(project_id=project_id)
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
base_queryset = base_queryset.order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# validation for guest user
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project_member = ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
)
if project_member.exists() and not project.guest_view_all_features:
base_queryset = base_queryset.filter(created_by=request.user)
queryset = queryset.filter(created_by=request.user)
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
+1 -1
View File
@@ -90,7 +90,7 @@ class GlobalSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
)
)[:100]
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
+1 -1
View File
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
"state__name",
"state__group",
"state__color",
),
)[:100],
status=status.HTTP_200_OK,
)
@@ -74,7 +74,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Get all active workspace members
workspace_members = self.get_queryset()
if workspace_member.role > 5:
serializer = WorkspaceMemberAdminSerializer(
workspace_members,
+96
View File
@@ -0,0 +1,96 @@
# Third party imports
from celery import shared_task
from opentelemetry import trace
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
Workspace,
Project,
Issue,
Module,
Cycle,
CycleIssue,
ModuleIssue,
Page,
WorkspaceMember,
)
@shared_task
def instance_traces():
# Get the tracer
tracer = trace.get_tracer(__name__)
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then return
if instance is None:
return
if instance.is_telemetry_enabled:
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute(
"is_telemetry_enabled", instance.is_telemetry_enabled
)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(
workspace=workspace
).count()
module_issue_count = ModuleIssue.objects.filter(
workspace=workspace
).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(
workspace=workspace
).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
@@ -9,7 +9,10 @@ from django.conf import settings
# Module imports
from plane.license.models import Instance
from plane.db.models import User
from plane.db.models import (
User,
)
from plane.license.bgtasks.tracer import instance_traces
class Command(BaseCommand):
@@ -21,16 +24,24 @@ class Command(BaseCommand):
"machine_signature", type=str, help="Machine signature"
)
def read_package_json(self):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"user_count": User.objects.filter(is_bot=False).count(),
}
return payload
def handle(self, *args, **options):
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then register this instance
if instance is None:
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
machine_signature = options.get(
"machine_signature", "machine-signature"
)
@@ -38,12 +49,7 @@ class Command(BaseCommand):
if not machine_signature:
raise CommandError("Machine signature is required")
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": machine_signature,
"user_count": User.objects.filter(is_bot=False).count(),
}
payload = self.read_package_json()
instance = Instance.objects.create(
instance_name="Plane Community Edition",
@@ -60,4 +66,15 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS("Instance already registered")
)
return
payload = self.read_package_json()
# Update the instance details
instance.last_checked_at = timezone.now()
instance.user_count = payload.get("user_count", 0)
instance.current_version = payload.get("version")
instance.latest_version = payload.get("version")
instance.save()
# Call the instance traces task
instance_traces.delay()
return
+25
View File
@@ -16,6 +16,17 @@ from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
# OpenTelemetry
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Secret Key
@@ -24,6 +35,19 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
trace.set_tracer_provider(TracerProvider(resource=resource))
# Configure the OTLP exporter
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
span_processor = BatchSpanProcessor(otlp_exporter)
trace.get_tracer_provider().add_span_processor(span_processor)
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
@@ -279,6 +303,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
"plane.license.bgtasks.tracer",
# management tasks
"plane.bgtasks.dummy_data_task",
)
+5
View File
@@ -62,3 +62,8 @@ zxcvbn==4.4.28
pytz==2024.1
# jwt
PyJWT==2.8.0
# OpenTelemetry
opentelemetry-api==1.27.0
opentelemetry-sdk==1.27.0
opentelemetry-instrumentation-django==0.48b0
opentelemetry-exporter-otlp==1.27.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.22.0",
"version": "0.23.0",
"description": "",
"main": "./src/server.ts",
"private": true,
+1 -1
View File
@@ -57,7 +57,7 @@ http {
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://live:3000/;
proxy_pass http://live:3000/live/;
}
location /spaces/ {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.22.0",
"version": "0.23.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"main": "./index.ts"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.22.0",
"version": "0.23.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -42,7 +42,6 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
imageBeingUploadedByOtherUser: boolean;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
@@ -57,7 +56,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
editor,
editorContainer,
setEditorContainer,
imageBeingUploadedByOtherUser,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
// states
@@ -189,18 +187,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
// if someone else is uploading the image, we show the loader with the same size as the image from the initially loaded attrs
if (imageBeingUploadedByOtherUser) {
return (
<div className="group/image-component relative inline-block max-w-full">
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
</div>
);
}
return (
<div
ref={containerRef}
@@ -23,7 +23,6 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
const [imageBeingUploadedByOtherUser, setImageBeingUploadedByOtherUser] = useState(false);
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
@@ -45,22 +44,13 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
} else {
setIsUploaded(false);
}
const nodeWidth = node.attrs.width;
if (nodeWidth && nodeWidth !== "35%" && !imageFromFileSystem && remoteImageSrc === null) {
setImageBeingUploadedByOtherUser(true);
} else {
setImageBeingUploadedByOtherUser(false);
}
}, [node.attrs.src, node.attrs.width, node.attrs.height, node.attrs.aspectRatio]);
}, [node.attrs.src]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{((isUploaded || imageFromFileSystem) && !failedToLoadImage) || imageBeingUploadedByOtherUser ? (
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
imageBeingUploadedByOtherUser={imageBeingUploadedByOtherUser}
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.22.0",
"version": "0.23.0",
"files": [
"library.js",
"next.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/helpers",
"version": "0.22.0",
"version": "0.23.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tailwind-config-custom",
"version": "0.22.0",
"version": "0.23.0",
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
+18 -3
View File
@@ -1,4 +1,4 @@
import type {TIssue, IIssueFilterOptions} from "@plane/types";
import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
@@ -43,6 +43,18 @@ export type TCycleEstimateDistribution = {
completion_chart: TCycleCompletionChartDistribution;
labels: (TCycleLabelsDistribution & TCycleEstimateDistributionBase)[];
};
export type TCycleProgress = {
date: string;
started: number;
actual: number;
pending: number;
ideal: number | null;
scope: number;
completed: number;
unstarted: number;
backlog: number;
cancelled: number;
};
export type TProgressSnapshot = {
total_issues: number;
@@ -90,6 +102,8 @@ export interface ICycle extends TProgressSnapshot {
};
workspace_id: string;
project_detail: IProjectDetails;
progress: any[];
version: number;
}
export interface CycleIssueResponse {
@@ -107,7 +121,7 @@ export interface CycleIssueResponse {
}
export type SelectCycleType =
| (ICycle & {actionType: "edit" | "delete" | "create-issue"})
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type CycleDateCheckData = {
@@ -116,4 +130,5 @@ export type CycleDateCheckData = {
cycle_id?: string;
};
export type TCyclePlotType = "burndown" | "points";
export type TCycleEstimateType = "issues" | "points";
export type TCyclePlotType = "burndown" | "burnup";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"files": [
"base.json",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.22.0",
"version": "0.23.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -1,7 +1,7 @@
import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search } from "lucide-react";
import { Check, ChevronDown, Info, Search } from "lucide-react";
import { createPortal } from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
@@ -11,6 +11,8 @@ import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import { cn } from "../../helpers";
// types
import { ICustomSearchSelectProps } from "./helper";
// local components
import { Tooltip } from "../tooltip";
export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
const {
@@ -95,11 +97,10 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex w-full items-center justify-between gap-1 text-xs ${
disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
className={`flex w-full items-center justify-between gap-1 text-xs ${disabled
? "cursor-not-allowed text-custom-text-200"
: "cursor-pointer hover:bg-custom-background-80"
} ${customButtonClassName}`}
onClick={toggleDropdown}
>
{customButton}
@@ -170,17 +171,32 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
"text-custom-text-400 opacity-60 cursor-not-allowed": option.disabled,
}
)
}
onClick={() => {
if (!multiple) closeDropdown();
}}
disabled={option.disabled}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
{option.tooltip && (
<>
{
typeof option.tooltip === "string" ? (
<Tooltip tooltipContent={option.tooltip}>
<Info className="h-3.5 w-3.5 flex-shrink-0 cursor-pointer text-custom-text-200" />
</Tooltip>
) : (
option.tooltip
)
}
</>
)}
</>
)}
</Combobox.Option>
+8 -6
View File
@@ -44,12 +44,14 @@ interface CustomSearchSelectProps {
onChange: any;
onClose?: () => void;
options:
| {
value: any;
query: string;
content: React.ReactNode;
}[]
| undefined;
| {
value: any;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
}[]
| undefined;
}
interface SingleValueProps {
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const DoneState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 10 11"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<circle cx="5" cy="5.5" r="4.4" stroke="#15A34A" stroke-width="1.2" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.5 5.59375L3.82582 6.91957L4.26777 6.47763L2.94194 5.15181L2.5 5.59375ZM4.26777 7.36152L7.36136 4.26793L6.91942 3.82599L3.82583 6.91958L4.26777 7.36152Z"
fill="#15A34A"
/>
</svg>
);
@@ -0,0 +1,17 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const InProgressState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<circle cx="6" cy="6.5" r="4.4" stroke="#EA8900" stroke-width="1.2" />
<circle cx="6" cy="6.5" r="2.4" stroke="#EA8900" stroke-width="1.2" stroke-dasharray="4 4" />
</svg>
);
+3
View File
@@ -28,3 +28,6 @@ export * from "./dropdown-icon";
export * from "./intake";
export * from "./user-activity-icon";
export * from "./favorite-folder-icon";
export * from "./planned-icon";
export * from "./in-progress-icon";
export * from "./done-icon";
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { ISvgIcons } from "./type";
export const PlannedState: React.FC<ISvgIcons> = ({ width = "10", height = "11", className, color }) => (
<svg
width={width}
height={height}
viewBox="0 0 12 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g clip-path="url(#clip0_3180_28635)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.11853 4.7C7.20073 4.88749 7.38342 5.01866 7.59656 5.02037C7.88848 5.02271 8.12698 4.7813 8.12925 4.48116L8.13344 3.92962C8.1348 3.74982 8.04958 3.58096 7.90581 3.47859C7.76203 3.37623 7.57832 3.3536 7.41509 3.41815L3.97959 4.77682C3.77547 4.85755 3.64077 5.05919 3.64077 5.28406L3.64077 9.0883C3.64077 9.27834 3.73732 9.45458 3.8954 9.55308C4.05347 9.65157 4.25011 9.65802 4.41396 9.57008L4.90523 9.30643C5.16402 9.16754 5.26431 8.83925 5.12922 8.57317C5.04115 8.39971 4.8748 8.29551 4.69795 8.28247L4.69795 5.65729L7.11853 4.7Z"
fill="#455068"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.00428 3.06914C5.08648 3.25663 5.26916 3.3878 5.4823 3.38951C5.77422 3.39185 6.01272 3.15044 6.015 2.8503L6.01918 2.29876C6.02054 2.11896 5.93532 1.9501 5.79155 1.84774C5.64777 1.74537 5.46406 1.72274 5.30084 1.78729L1.86534 3.14597C1.66121 3.22669 1.52652 3.42834 1.52652 3.6532L1.52652 7.45745C1.52652 7.64749 1.62307 7.82372 1.78114 7.92222C1.93922 8.02071 2.13585 8.02716 2.29971 7.93922L2.79097 7.67557C3.04977 7.53668 3.15005 7.20839 3.01496 6.94231C2.92689 6.76885 2.76054 6.66465 2.5837 6.65161L2.5837 4.02643L5.00428 3.06914Z"
fill="#455068"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.473 9.34799C10.4728 9.57269 10.3382 9.77413 10.1342 9.85482L6.70129 11.2129C6.53874 11.2772 6.35582 11.255 6.21225 11.1536C6.06867 11.0523 5.98288 10.8847 5.98288 10.7056L5.98288 6.90139C5.98288 6.67653 6.11757 6.47489 6.3217 6.39416L9.7572 5.03548C9.91981 4.97118 10.1028 4.99338 10.2464 5.09484C10.3899 5.19629 10.4757 5.36397 10.4756 5.5431L10.473 9.34799ZM9.41784 6.33426L7.04006 7.27463L7.04006 9.91423L9.41605 8.97431L9.41784 6.33426Z"
fill="#455068"
/>
</g>
<defs>
<clipPath id="clip0_3180_28635">
<rect width="12" height="12" fill="white" transform="translate(0 0.5)" />
</clipPath>
</defs>
</svg>
);
+3 -2
View File
@@ -16,10 +16,11 @@ const Loader = ({ children, className = "" }: Props) => (
type ItemProps = {
height?: string;
width?: string;
className?: string;
};
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto" }) => (
<div className="rounded-md bg-custom-background-80" style={{ height: height, width: width }} />
const Item: React.FC<ItemProps> = ({ height = "auto", width = "auto", className = "" }) => (
<div className={cn("rounded-md bg-custom-background-80", className)} style={{ height: height, width: width }} />
);
Loader.Item = Item;
@@ -9,7 +9,7 @@ interface ICircularProgressIndicator {
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (props) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
const { size = 40, percentage = 25, strokeWidth = 6, strokeColor = "stroke-custom-primary-100", children } = props;
const sqSize = size;
const radius = (size - strokeWidth) / 2;
@@ -27,7 +27,7 @@ export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
strokeWidth={`${strokeWidth}px`}
style={{ filter: "url(#filter0_bi_377_19141)" }}
/>
<defs>
{/* <defs>
<filter
id="filter0_bi_377_19141"
x="-3.57544"
@@ -53,9 +53,9 @@ export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
<feColorMatrix type="matrix" values="0 0 0 0 0.63125 0 0 0 0 0.6625 0 0 0 0 0.75 0 0 0 0.35 0" />
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_377_19141" />
</filter>
</defs>
</defs> */}
<circle
className="fill-none stroke-custom-primary-100 "
className={`fill-none ${strokeColor}`}
cx={size / 2}
cy={size / 2}
r={radius}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -70,14 +70,14 @@ const CycleDetailPage = observer(() => {
{cycleId && !isSidebarCollapsed && (
<div
className={cn(
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
"flex h-full w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
)}
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={cycleId.toString()} handleClose={toggleSidebar} />
<CycleDetailsSidebar handleClose={toggleSidebar} />
</div>
)}
</div>
@@ -0,0 +1 @@
export * from "./root";
@@ -12,12 +12,12 @@ import {
CycleListGroupHeader,
CyclesListItem,
} from "@/components/cycles";
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { useCycle } from "@/hooks/store";
import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
import useCyclesDetails from "./use-cycles-details";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -0,0 +1 @@
export * from "./sidebar-chart";
@@ -0,0 +1,57 @@
import { Fragment } from "react";
import { TCycleDistribution, TCycleEstimateDistribution } from "@plane/types";
import { Loader } from "@plane/ui";
import ProgressChart from "@/components/core/sidebar/progress-chart";
type ProgressChartProps = {
chartDistributionData: TCycleEstimateDistribution | TCycleDistribution | undefined;
cycleStartDate: Date | undefined;
cycleEndDate: Date | undefined;
totalEstimatePoints: number;
totalIssues: number;
plotType: string;
};
export const SidebarBaseChart = (props: ProgressChartProps) => {
const { chartDistributionData, cycleStartDate, cycleEndDate, totalEstimatePoints, totalIssues, plotType } = props;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return (
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
);
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./active-cycle";
export * from "./analytics-sidebar";
@@ -1,12 +1,23 @@
import { Control } from "react-hook-form";
// types
import { TIssue } from "@plane/types";
import { TBulkIssueProperties, TIssue } from "@plane/types";
type TIssueTypeSelectProps = {
control: Control<TIssue>;
export type TIssueFields = TIssue & TBulkIssueProperties;
export type TIssueTypeDropdownVariant = "xs" | "sm";
export type TIssueTypeSelectProps<T extends Partial<TIssueFields>> = {
control: Control<T>;
projectId: string | null;
disabled?: boolean;
handleFormChange: () => void;
variant?: TIssueTypeDropdownVariant;
placeholder?: string;
isRequired?: boolean;
renderChevron?: boolean;
dropDownContainerClassName?: string;
showMandatoryFieldInfo?: boolean; // Show info about mandatory fields
handleFormChange?: () => void;
};
export const IssueTypeSelect: React.FC<TIssueTypeSelectProps> = () => <></>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const IssueTypeSelect = <T extends Partial<TIssueFields>>(props: TIssueTypeSelectProps<T>) => <></>;
@@ -1,4 +1,3 @@
export * from "./root";
export * from "./header";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
@@ -1,7 +1,7 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ICycle, TCyclePlotType } from "@plane/types";
import { ICycle, TCycleEstimateType, TCyclePlotType } from "@plane/types";
import { CustomSelect, Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
@@ -19,22 +19,22 @@ export type ActiveCycleProductivityProps = {
};
const cycleBurnDownChartOptions = [
{ value: "burndown", label: "Issues" },
{ value: "issues", label: "Issues" },
{ value: "points", label: "Points" },
];
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
// hooks
const { getPlotTypeByCycleId, setPlotType } = useCycle();
const { getEstimateTypeByCycleId, setEstimateType } = useCycle();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// derived values
const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown";
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
const onChange = async (value: TCyclePlotType) => {
const onChange = async (value: TCycleEstimateType) => {
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
setPlotType(cycle.id, value);
setEstimateType(cycle.id, value);
};
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
@@ -43,7 +43,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
const chartDistributionData =
cycle && plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return cycle && completionChartDistributionData ? (
@@ -55,8 +55,8 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
{isCurrentEstimateTypeIsPoints && (
<div className="relative flex items-center gap-2">
<CustomSelect
value={plotType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>}
value={estimateType}
label={<span>{cycleBurnDownChartOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
>
@@ -85,7 +85,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
<span>Current</span>
</div>
</div>
{plotType === "points" ? (
{estimateType === "points" ? (
<span>{`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`}</span>
) : (
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
@@ -95,7 +95,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
<div className="relative h-full">
{completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
{estimateType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycle.start_date ?? ""}
@@ -10,7 +10,7 @@ import { useCycle, useIssues } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId: string | null;
cycleId: string | null | undefined;
}
const useCyclesDetails = (props: IActiveCycleDetails) => {
@@ -1,3 +1,5 @@
export * from "./root";
export * from "./issue-progress";
export * from "./progress-stats";
export * from "./sidebar-header";
export * from "./sidebar-details";
@@ -5,12 +5,11 @@ import isEmpty from "lodash/isEmpty";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect, Loader, Spinner } from "@plane/ui";
import { ICycle, IIssueFilterOptions, TCycleEstimateType, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { CycleProgressStats } from "@/components/cycles";
// constants
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
@@ -19,6 +18,7 @@ import { getDate } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useCycle, useProjectEstimates } from "@/hooks/store";
// plane web constants
import { SidebarBaseChart } from "@/plane-web/components/cycles/analytics-sidebar";
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type TCycleAnalyticsProgress = {
@@ -27,11 +27,6 @@ type TCycleAnalyticsProgress = {
cycleId: string;
};
const cycleBurnDownChartOptions = [
{ value: "burndown", label: "Issues" },
{ value: "points", label: "Points" },
];
const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
if (!cycleDetails || cycleDetails === null) return cycleDetails;
@@ -47,6 +42,18 @@ const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
return updatedCycleDetails;
};
type options = {
value: string;
label: string;
};
export const cycleChartOptions: options[] = [
{ value: "burndown", label: "Burn-down" },
{ value: "burnup", label: "Burn-up" },
];
export const cycleEstimateOptions: options[] = [
{ value: "issues", label: "issues" },
{ value: "points", label: "points" },
];
export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((props) => {
// props
const { workspaceSlug, projectId, cycleId } = props;
@@ -55,7 +62,15 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const peekCycle = searchParams.get("peekCycle") || undefined;
// hooks
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { getPlotTypeByCycleId, setPlotType, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
const {
getPlotTypeByCycleId,
getEstimateTypeByCycleId,
setPlotType,
getCycleById,
fetchCycleDetails,
fetchArchivedCycleDetails,
setEstimateType,
} = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
@@ -65,6 +80,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
// derived values
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
const estimateDetails =
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
@@ -76,7 +92,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const progressHeaderPercentage = cycleDetails
? plotType === "points"
? estimateType === "points"
? completedEstimatePoints != 0 && totalEstimatePoints != 0
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
: 0
@@ -86,21 +102,22 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
: 0;
const chartDistributionData =
plotType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const groupedIssues = useMemo(
() => ({
backlog: plotType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
backlog:
estimateType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
unstarted:
plotType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
started: plotType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
estimateType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
started:
estimateType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
completed:
plotType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
estimateType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
cancelled:
plotType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
estimateType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
}),
[plotType, cycleDetails]
[estimateType, cycleDetails]
);
const cycleStartDate = getDate(cycleDetails?.start_date);
@@ -111,8 +128,8 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
const isArchived = !!cycleDetails?.archived_at;
// handlers
const onChange = async (value: TCyclePlotType) => {
setPlotType(cycleId, value);
const onChange = async (value: TCycleEstimateType) => {
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
setLoader(true);
@@ -124,7 +141,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
setLoader(false);
} catch (error) {
setLoader(false);
setPlotType(cycleId, plotType);
setEstimateType(cycleId, estimateType);
}
};
@@ -161,40 +178,16 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
if (!cycleDetails) return <></>;
return (
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
<div className="border-t border-custom-border-200 space-y-4 py-5">
<Disclosure defaultOpen={isCycleDateValid ? true : false}>
{({ open }) => (
<div className="space-y-6">
<div className="flex flex-col">
{/* progress bar header */}
{isCycleDateValid ? (
<div className="relative w-full flex justify-between items-center gap-2">
<Disclosure.Button className="relative flex items-center gap-2 w-full">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
{progressHeaderPercentage > 0 && (
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
)}
</Disclosure.Button>
{isCurrentEstimateTypeIsPoints && (
<>
<div>
<CustomSelect
value={plotType}
label={
<span>{cycleBurnDownChartOptions.find((v) => v.value === plotType)?.label ?? "None"}</span>
}
onChange={onChange}
maxHeight="lg"
>
{cycleBurnDownChartOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
{loader && <Spinner className="h-3 w-3" />}
</>
)}
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
@@ -206,67 +199,45 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
) : (
<div className="relative w-full flex justify-between items-center gap-2">
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleDetails?.start_date && cycleDetails?.end_date
? "This cycle isn't active yet."
: "Invalid date. Please enter valid date."}
</span>
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="space-y-4">
{/* progress burndown chart */}
<div>
<div className="relative flex items-center gap-2">
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
{plotType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalEstimatePoints}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
startDate={cycleStartDate}
endDate={cycleEndDate}
totalIssues={totalIssues}
plotTitle={"issues"}
/>
)}
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
<Disclosure.Panel className="flex flex-col">
<div className="relative flex items-center justify-between gap-2 pt-4">
<CustomSelect
value={estimateType}
label={<span>{cycleEstimateOptions.find((v) => v.value === estimateType)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="border-none rounded text-sm font-medium"
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
<div className="py-4">
<SidebarBaseChart
chartDistributionData={chartDistributionData}
cycleStartDate={cycleStartDate}
cycleEndDate={cycleEndDate}
totalEstimatePoints={totalEstimatePoints}
totalIssues={totalIssues}
plotType={plotType}
/>
</div>
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full border-t border-custom-border-200 pt-5">
<div className="w-full border-t border-custom-border-200 py-4">
<CycleProgressStats
cycleId={cycleId}
plotType={plotType}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
isEditable={Boolean(!peekCycle)}
size="xs"
roundedTab={false}
@@ -219,6 +219,10 @@ export const StateStatComponent = observer((props: TStateStatComponent) => {
});
const progressStats = [
{
key: "stat-states",
title: "States",
},
{
key: "stat-assignees",
title: "Assignees",
@@ -227,10 +231,6 @@ const progressStats = [
key: "stat-labels",
title: "Labels",
},
{
key: "stat-states",
title: "States",
},
];
type TCycleProgressStats = {
@@ -341,6 +341,14 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
))}
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-assignees"}>
<AssigneeStatComponent
distribution={distributionAssigneeData}
@@ -357,14 +365,6 @@ export const CycleProgressStats: FC<TCycleProgressStats> = observer((props) => {
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
<Tab.Panel key={"stat-states"}>
<StateStatComponent
distribution={distributionStateData}
totalIssuesCount={totalIssuesCount}
isEditable={isEditable}
handleFiltersUpdate={handleFiltersUpdate}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
@@ -1,192 +1,33 @@
"use client";
import React, { useEffect, useState } from "react";
import isEmpty from "lodash/isEmpty";
import React from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// icons
import { ArchiveRestoreIcon, LinkIcon, Trash2, ChevronRight, CalendarClock, SquareUser } from "lucide-react";
// types
import { ICycle } from "@plane/types";
// ui
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
import { Loader } from "@plane/ui";
// components
import { ArchiveCycleModal, CycleDeleteModal, CycleAnalyticsProgress } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { CycleAnalyticsProgress, CycleSidebarHeader, CycleSidebarDetails } from "@/components/cycles";
import useCyclesDetails from "../active-cycle/use-cycles-details";
// hooks
import { useEventTracker, useCycle, useMember, useProjectEstimates, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
// services
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { CycleService } from "@/services/cycle.service";
type Props = {
cycleId: string;
handleClose: () => void;
isArchived?: boolean;
cycleId?: string;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
// services
const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose, isArchived } = props;
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const { handleClose, isArchived } = props;
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
const { workspaceSlug, projectId, cycleId } = useParams();
// store hooks
const { setTrackElement, captureCycleEvent } = useEventTracker();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { allowPermissions } = useUserPermissions();
const { getCycleById, updateCycleDetails, restoreCycle } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
// form info
const { control, reset } = useForm({
defaultValues,
const { cycle: cycleDetails } = useCyclesDetails({
workspaceSlug: workspaceSlug.toString(),
projectId: projectId.toString(),
cycleId: cycleId?.toString() || props.cycleId,
});
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
element: "Right side-peek",
state: "SUCCESS",
},
});
})
.catch(() => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...data,
element: "Right side-peek",
state: "FAILED",
},
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
});
};
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
return res.status;
} catch (err) {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload, "date_range");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
if (!cycleDetails)
return (
<Loader className="px-5">
@@ -202,248 +43,26 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</Loader>
);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
// NOTE: validate if the cycle is snapshot and the estimate system is points
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
? estimateType && estimateType?.type == EEstimateSystem.POINTS
? true
: false
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
? false
: true;
const issueCount =
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
? cycleDetails.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}`
: cycleDetails.total_issues === 0
? "0 Issue"
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
const issueEstimatePointCount =
isCompleted && !isEmpty(cycleDetails.progress_snapshot)
? cycleDetails.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails.progress_snapshot.completed_estimate_points}/${cycleDetails.progress_snapshot.total_estimate_points}`
: cycleDetails.total_issues === 0
? "0 Issue"
: `${cycleDetails.completed_estimate_points}/${cycleDetails.total_estimate_points}`;
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date);
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<div className="relative">
{cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>
<div className="relative pb-2">
<div className="flex flex-col gap-5 w-full">
<CycleSidebarHeader
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleDetails={cycleDetails}
isArchived={isArchived}
handleClose={handleClose}
/>
<CycleSidebarDetails projectId={projectId.toString()} cycleDetails={cycleDetails} />
</div>
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails?.id}
/>
)}
<>
<div
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
>
<div>
<button
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-3 pt-2">
<div className="flex items-center gap-5">
{currentCycle && (
<span
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current" && daysLeft !== undefined
? `${daysLeft} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
</div>
{cycleDetails.description && (
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Date range</span>
</div>
<div className="h-7 w-3/5">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonContainerClassName="w-full"
buttonVariant="background-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
/>
)}
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails?.id}
/>
)}
</>
</div>
);
});
@@ -0,0 +1,138 @@
"use client";
import React, { FC } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import { LayersIcon, SquareUser, Users } from "lucide-react";
// ui
import { ICycle } from "@plane/types";
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
// types
// hooks
import { useMember, useProjectEstimates } from "@/hooks/store";
// plane web
import { EEstimateSystem } from "@/plane-web/constants/estimates";
type Props = {
projectId: string;
cycleDetails: ICycle;
};
export const CycleSidebarDetails: FC<Props> = observer((props) => {
const { projectId, cycleDetails } = props;
// hooks
const { getUserDetails } = useMember();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const issueCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.progress_snapshot?.completed_issues}/${cycleDetails?.progress_snapshot?.total_issues}`
: cycleDetails?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.completed_issues}/${cycleDetails?.total_issues}`;
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
? estimateType && estimateType?.type == EEstimateSystem.POINTS
? true
: false
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
? false
: true;
const issueEstimatePointCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot.total_issues === 0
? "0 Issue"
: `${cycleDetails?.progress_snapshot.completed_estimate_points}/${cycleDetails?.progress_snapshot.total_estimate_points}`
: cycleDetails?.total_issues === 0
? "0 Issue"
: `${cycleDetails?.completed_estimate_points}/${cycleDetails?.total_estimate_points}`;
return (
<div className="flex flex-col gap-5 w-full">
{cycleDetails?.description && (
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<Users className="h-4 w-4" />
<span className="text-base">Members</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
{cycleDetails?.assignee_ids && cycleDetails.assignee_ids.length > 0 ? (
<>
<AvatarGroup showTooltip>
{cycleDetails.assignee_ids.map((member) => {
const memberDetails = getUserDetails(member);
return (
<Avatar
key={memberDetails?.id}
name={memberDetails?.display_name ?? ""}
src={memberDetails?.avatar ?? ""}
showTooltip={false}
/>
);
})}
</AvatarGroup>
</>
) : (
<span className="px-1.5 text-sm text-custom-text-300">No assignees</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Points</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,326 @@
"use client";
import React, { FC, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { ArchiveIcon, ArchiveRestoreIcon, ChevronRight, EllipsisIcon, LinkIcon, Trash2 } from "lucide-react";
// types
import { ICycle } from "@plane/types";
// ui
import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { DateRangeDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker";
// helpers
import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCycle, useEventTracker, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// services
import { CycleService } from "@/services/cycle.service";
// local components
import { ArchiveCycleModal } from "../archived-cycles";
import { CycleDeleteModal } from "../delete-modal";
type Props = {
workspaceSlug: string;
projectId: string;
cycleDetails: ICycle;
handleClose: () => void;
isArchived?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
const cycleService = new CycleService();
export const CycleSidebarHeader: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleDetails, handleClose, isArchived = false } = props;
// router
const router = useAppRouter();
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// hooks
const { allowPermissions } = useUserPermissions();
const { updateCycleDetails, restoreCycle } = useCycle();
const { setTrackElement, captureCycleEvent } = useEventTracker();
// form info
const { control, reset } = useForm({
defaultValues,
});
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleDetails.id)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/archives/cycles`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Some error occurred",
});
});
};
const submitChanges = (data: Partial<ICycle>, changedProperty: string) => {
if (!workspaceSlug || !projectId || !cycleDetails.id) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
element: "Right side-peek",
state: "SUCCESS",
},
});
})
.catch(() => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
payload: {
...data,
element: "Right side-peek",
state: "FAILED",
},
});
});
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, payload);
return res.status;
} catch (err) {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails?.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload, "date_range");
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message:
"You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<>
{cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleDetails.id}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>
)}
<div className="sticky z-10 top-0 pt-2 flex items-center justify-between bg-custom-sidebar-background-100">
<div className="flex items-center justify-center size-5">
<button
className="flex size-4 items-center justify-center rounded-full bg-custom-border-200"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
<div className="flex items-center gap-3">
{!isArchived && (
<button onClick={handleCopyText} className="size-4">
<LinkIcon className="size-3.5 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu
placement="bottom-end"
customButtonClassName="size-4"
customButton={<EllipsisIcon className="size-3.5 text-custom-text-300" />}
>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-start justify-between gap-3 pt-2">
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
{currentCycle && (
<span
className="flex h-6 min-w-20 px-3 items-center justify-center rounded text-center text-xs font-medium"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.title}
</span>
)}
</div>
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
/>
)}
/>
)}
/>
</div>
</>
);
});
@@ -41,17 +41,13 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
{peekCycle && (
<div
ref={ref}
className="flex h-full w-full max-w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 fixed md:relative right-0 z-[9]"
className="flex h-full w-full max-w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 fixed md:relative right-0 z-[9]"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar
cycleId={peekCycle?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
<CycleDetailsSidebar handleClose={handleClose} isArchived={isArchived} cycleId={peekCycle} />
</div>
)}
</>
@@ -2,8 +2,9 @@
import React, { FC, MouseEvent, useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { Users } from "lucide-react";
import { Eye, Users } from "lucide-react";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
@@ -18,7 +19,9 @@ import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { generateQueryParams } from "@/helpers/router.helper";
import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { CycleService } from "@/services/cycle.service";
@@ -31,6 +34,7 @@ type Props = {
cycleId: string;
cycleDetails: ICycle;
parentRef: React.RefObject<HTMLDivElement>;
isActive?: boolean;
};
const defaultValues: Partial<ICycle> = {
@@ -39,9 +43,13 @@ const defaultValues: Partial<ICycle> = {
};
export const CycleListItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
// hooks
const { isMobile } = usePlatformOS();
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { captureEvent } = useEventTracker();
@@ -183,77 +191,90 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const isCompleted = cycleStatus === "completed";
const isDisabled = !isEditingAllowed || isArchived || isCompleted;
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
return (
<>
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
/>
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`
: `${currentCycle.label}`}
</div>
<button
onClick={openCycleOverview}
className={`z-[1] flex text-custom-primary-200 text-xs gap-1 flex-shrink-0 ${isMobile || isActive ? "flex" : "hidden group-hover:flex"}`}
>
<Eye className="h-4 w-4 my-auto text-custom-primary-200" />
<span>More details</span>
</button>
{!isActive && (
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
/>
)}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<Users className="h-4 w-4 text-custom-text-300" />
)}
</div>
</Tooltip>
{!isActive && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<Users className="h-4 w-4 text-custom-text-300" />
)}
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
@@ -106,14 +106,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
)}
</CircularProgressIndicator>
}
appendTitleElement={
<button
onClick={openCycleOverview}
className={`z-[1] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
}
actionableItems={
<CycleListItemAction
workspaceSlug={workspaceSlug}
+4 -4
View File
@@ -1,10 +1,11 @@
import { FC } from "react";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Disclosure } from "@headlessui/react";
// components
import { ContentWrapper, ERowVariant } from "@plane/ui";
import { ListLayout } from "@/components/core/list";
import { ActiveCycleRoot, CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles";
import { CycleListGroupHeader, CyclePeekOverview, CyclesListMap } from "@/components/cycles";
import { ActiveCycleRoot } from "@/plane-web/components/cycles";
export interface ICyclesList {
completedCycleIds: string[];
@@ -27,7 +28,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
</>
) : (
<>
<ActiveCycleRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
<ActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} />
{upcomingCycleIds && (
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
@@ -49,7 +50,6 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
)}
</Disclosure>
)}
<Disclosure as="div" className="flex flex-shrink-0 flex-col pb-7">
{({ open }) => (
<>
@@ -6,7 +6,7 @@ import React from "react";
import { Tooltip } from "@plane/ui";
// types
// constants
import { EIssueLayoutTypes,ISSUE_LAYOUTS } from "@/constants/issue";
import { EIssueLayoutTypes, ISSUE_LAYOUTS } from "@/constants/issue";
import { usePlatformOS } from "@/hooks/use-platform-os";
// hooks
@@ -20,6 +20,12 @@ export const LayoutSelection: React.FC<Props> = (props) => {
const { layouts, onChange, selectedLayout } = props;
const { isMobile } = usePlatformOS();
const handleOnChange = (layoutKey: EIssueLayoutTypes) => {
if (selectedLayout !== layoutKey) {
onChange(layoutKey);
}
};
return (
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout) => (
@@ -29,7 +35,7 @@ export const LayoutSelection: React.FC<Props> = (props) => {
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
selectedLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => onChange(layout.key)}
onClick={() => handleOnChange(layout.key)}
>
<layout.icon
size={14}
@@ -199,21 +199,24 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
});
};
const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => {
if (workspaceSlug) {
let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
if (kanbanFilters.includes(value)) {
kanbanFilters = kanbanFilters.filter((_value) => _value != value);
} else {
kanbanFilters.push(value);
const handleCollapsedGroups = useCallback(
(toggle: "group_by" | "sub_group_by", value: string) => {
if (workspaceSlug) {
let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
if (collapsedGroups.includes(value)) {
collapsedGroups = collapsedGroups.filter((_value) => _value != value);
} else {
collapsedGroups.push(value);
}
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
[toggle]: collapsedGroups,
});
}
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS, {
[toggle]: kanbanFilters,
});
}
};
},
[workspaceSlug, issuesFilter, projectId, updateFilters]
);
const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] };
return (
<IssueLayoutHOC layout={EIssueLayoutTypes.KANBAN}>
@@ -258,8 +261,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
orderBy={orderBy}
updateIssue={updateIssue}
quickActions={renderQuickActions}
handleKanbanFilters={handleKanbanFilters}
kanbanFilters={kanbanFilters}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
enableQuickIssueCreate={enableQuickAdd}
showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true}
quickAddCallback={quickAddIssue}
@@ -46,8 +46,8 @@ export interface IKanBan {
sub_group_index?: number;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: any;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
enableQuickIssueCreate?: boolean;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
@@ -71,8 +71,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
sub_group_id = "null",
updateIssue,
quickActions,
kanbanFilters,
handleKanbanFilters,
collapsedGroups,
handleCollapsedGroups,
enableQuickIssueCreate,
quickAddCallback,
loadMoreIssues,
@@ -133,7 +133,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
if ((getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0) groupVisibility.showGroup = true;
else groupVisibility.showGroup = false;
}
if (kanbanFilters?.group_by.includes(_list.id)) groupVisibility.showIssues = false;
if (collapsedGroups?.group_by.includes(_list.id)) groupVisibility.showIssues = false;
return groupVisibility;
}
};
@@ -176,8 +176,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
issuePayload={subList.payload}
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy}
addIssuesToView={addIssuesToView}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
/>
</div>
)}
@@ -24,8 +24,8 @@ interface IHeaderGroupByCard {
icon?: React.ReactNode;
title: string;
count: number;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: any;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
issuePayload: Partial<TIssue>;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
@@ -38,13 +38,13 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
icon,
title,
count,
kanbanFilters,
handleKanbanFilters,
collapsedGroups,
handleCollapsedGroups,
issuePayload,
disableIssueCreation,
addIssuesToView,
} = props;
const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id);
const verticalAlignPosition = sub_group_by ? false : collapsedGroups?.group_by.includes(column_id);
// states
const [isOpen, setIsOpen] = React.useState(false);
const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false);
@@ -133,7 +133,7 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
{sub_group_by === null && (
<div
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
onClick={() => handleKanbanFilters("group_by", column_id)}
onClick={() => handleCollapsedGroups("group_by", column_id)}
>
{verticalAlignPosition ? (
<Maximize2 width={14} strokeWidth={2} />
@@ -9,19 +9,19 @@ interface IHeaderSubGroupByCard {
title: string;
count: number;
column_id: string;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
}
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
const { icon, title, count, column_id, kanbanFilters, handleKanbanFilters } = props;
const { icon, title, count, column_id, collapsedGroups, handleCollapsedGroups } = props;
return (
<div
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-1 rounded-sm py-1.5 cursor-pointer`}
onClick={() => handleKanbanFilters("sub_group_by", column_id)}
onClick={() => handleCollapsedGroups("sub_group_by", column_id)}
>
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
{kanbanFilters?.sub_group_by.includes(column_id) ? (
{collapsedGroups?.sub_group_by.includes(column_id) ? (
<ChevronDown width={14} strokeWidth={2} />
) : (
<ChevronUp width={14} strokeWidth={2} />
@@ -35,8 +35,8 @@ interface ISubGroupSwimlaneHeader {
sub_group_by: TIssueGroupByOptions | undefined;
group_by: TIssueGroupByOptions | undefined;
list: IGroupByColumn[];
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
showEmptyGroup: boolean;
}
@@ -53,7 +53,7 @@ const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGro
};
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
({ getGroupIssueCount, sub_group_by, group_by, list, kanbanFilters, handleKanbanFilters, showEmptyGroup }) => (
({ getGroupIssueCount, sub_group_by, group_by, list, collapsedGroups, handleCollapsedGroups, showEmptyGroup }) => (
<div className="relative flex h-max min-h-full w-full items-center gap-2">
{list &&
list.length > 0 &&
@@ -73,8 +73,8 @@ const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
icon={_list.icon}
title={_list.name}
count={groupCount}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
issuePayload={_list.payload}
/>
</div>
@@ -96,8 +96,8 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
displayProperties: IIssueDisplayProperties | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
disableIssueCreation?: boolean;
enableQuickIssueCreate: boolean;
@@ -120,8 +120,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
updateIssue,
quickActions,
displayProperties,
kanbanFilters,
handleKanbanFilters,
collapsedGroups,
handleCollapsedGroups,
loadMoreIssues,
showEmptyGroup,
enableQuickIssueCreate,
@@ -147,7 +147,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
else subGroupVisibility.showGroup = false;
}
if (kanbanFilters?.sub_group_by.includes(_list.id)) subGroupVisibility.showIssues = false;
if (collapsedGroups?.sub_group_by.includes(_list.id)) subGroupVisibility.showIssues = false;
return subGroupVisibility;
};
@@ -168,8 +168,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
icon={_list.icon}
title={_list.name || ""}
count={issueCount}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
/>
</Row>
</div>
@@ -187,8 +187,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
subGroupIndex={subGroupIndex}
updateIssue={updateIssue}
quickActions={quickActions}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
showEmptyGroup={showEmptyGroup}
enableQuickIssueCreate={enableQuickIssueCreate}
disableIssueCreation={disableIssueCreation}
@@ -224,8 +224,8 @@ export interface IKanBanSwimLanes {
group_by: TIssueGroupByOptions | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
quickActions: TRenderQuickActions;
kanbanFilters: TIssueKanbanFilters;
handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void;
collapsedGroups: TIssueKanbanFilters;
handleCollapsedGroups: (toggle: "group_by" | "sub_group_by", value: string) => void;
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
showEmptyGroup: boolean;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
@@ -249,8 +249,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
orderBy,
updateIssue,
quickActions,
kanbanFilters,
handleKanbanFilters,
collapsedGroups,
handleCollapsedGroups,
loadMoreIssues,
showEmptyGroup,
handleOnDrop,
@@ -303,8 +303,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
getGroupIssueCount={getGroupIssueCount}
group_by={group_by}
sub_group_by={sub_group_by}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
list={groupByList}
showEmptyGroup={showEmptyGroup}
/>
@@ -322,8 +322,8 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
orderBy={orderBy}
updateIssue={updateIssue}
quickActions={quickActions}
kanbanFilters={kanbanFilters}
handleKanbanFilters={handleKanbanFilters}
collapsedGroups={collapsedGroups}
handleCollapsedGroups={handleCollapsedGroups}
loadMoreIssues={loadMoreIssues}
showEmptyGroup={showEmptyGroup}
handleOnDrop={handleOnDrop}
@@ -1,9 +1,10 @@
import { FC, useCallback, useEffect } from "react";
import { observer } from "mobx-react";
// types
import { GroupByColumnTypes, TGroupedIssues } from "@plane/types";
import { useParams } from "next/navigation";
import { GroupByColumnTypes, TGroupedIssues, TIssueKanbanFilters } from "@plane/types";
// constants
import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue";
// hooks
import { useIssues, useUserPermissions } from "@/hooks/store";
// hooks
@@ -59,6 +60,10 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
const group_by = (displayFilters?.group_by || null) as GroupByColumnTypes | null;
const showEmptyGroup = displayFilters?.show_empty_groups ?? false;
const { workspaceSlug, projectId } = useParams();
const {updateFilters} = useIssuesActions(storeType);
const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] } as TIssueKanbanFilters;
useEffect(() => {
fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId);
}, [fetchIssues, storeType, group_by, viewId]);
@@ -107,6 +112,25 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
[fetchNextIssues]
);
// kanbanFilters and EIssueFilterType.KANBAN_FILTERS are used becuase the state is shared between kanban view and list view
const handleCollapsedGroups = useCallback(
(value: string) => {
if (workspaceSlug) {
let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.group_by || [];
if (collapsedGroups.includes(value)) {
collapsedGroups = collapsedGroups.filter((_value) => _value != value);
} else {
collapsedGroups.push(value);
}
updateFilters(projectId?.toString() ?? "", EIssueFilterType.KANBAN_FILTERS,
{ group_by: collapsedGroups } as TIssueKanbanFilters
);
}
},
[workspaceSlug, issuesFilter, projectId, updateFilters]
);
return (
<IssueLayoutHOC layout={EIssueLayoutTypes.LIST}>
<div className={`relative size-full bg-custom-background-90`}>
@@ -127,6 +151,8 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
addIssuesToView={addIssuesToView}
isCompletedCycle={isCompletedCycle}
handleOnDrop={handleOnDrop}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
/>
</div>
</IssueLayoutHOC>
@@ -14,6 +14,7 @@ import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IGroupByColumn,
TIssueKanbanFilters,
} from "@plane/types";
// components
import { MultipleSelectGroup } from "@/components/core";
@@ -47,6 +48,8 @@ export interface IList {
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
isCompletedCycle?: boolean;
loadMoreIssues: (groupId?: string) => void;
handleCollapsedGroups: (value: string) => void;
collapsedGroups : TIssueKanbanFilters;
}
export const List: React.FC<IList> = observer((props) => {
@@ -67,6 +70,8 @@ export const List: React.FC<IList> = observer((props) => {
addIssuesToView,
isCompletedCycle = false,
loadMoreIssues,
handleCollapsedGroups,
collapsedGroups
} = props;
const storeType = useIssueStoreType();
@@ -162,6 +167,8 @@ export const List: React.FC<IList> = observer((props) => {
loadMoreIssues={loadMoreIssues}
containerRef={containerRef}
selectionHelpers={helpers}
handleCollapsedGroups={handleCollapsedGroups}
collapsedGroups={collapsedGroups}
/>
))}
</div>
@@ -26,10 +26,10 @@ interface IHeaderGroupByCard {
count: number;
issuePayload: Partial<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
toggleListGroup: () => void;
disableIssueCreation?: boolean;
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
selectionHelpers: TSelectionHelper;
handleCollapsedGroups: (value: string) => void;
}
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
@@ -43,7 +43,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
disableIssueCreation,
addIssuesToView,
selectionHelpers,
toggleListGroup,
handleCollapsedGroups
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
@@ -108,7 +108,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
<div
className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer"
onClick={toggleListGroup}
onClick={() => handleCollapsedGroups(groupID)}
>
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
@@ -14,6 +14,7 @@ import {
TIssueOrderByOptions,
TIssue,
IIssueDisplayProperties,
TIssueKanbanFilters,
} from "@plane/types";
import { Row, setToast, TOAST_TYPE } from "@plane/ui";
// components
@@ -60,6 +61,8 @@ interface Props {
showEmptyGroup?: boolean;
loadMoreIssues: (groupId?: string) => void;
selectionHelpers: TSelectionHelper;
handleCollapsedGroups: (value: string) => void;
collapsedGroups: TIssueKanbanFilters;
}
export const ListGroup = observer((props: Props) => {
@@ -84,11 +87,13 @@ export const ListGroup = observer((props: Props) => {
showEmptyGroup,
loadMoreIssues,
selectionHelpers,
handleCollapsedGroups,
collapsedGroups,
} = props;
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const [dragColumnOrientation, setDragColumnOrientation] = useState<"justify-start" | "justify-end">("justify-start");
const [isExpanded, setIsExpanded] = useState(true);
const isExpanded = !(collapsedGroups?.group_by.includes(group.id))
const groupRef = useRef<HTMLDivElement | null>(null);
const { projectId } = useParams();
@@ -129,10 +134,6 @@ export const ListGroup = observer((props: Props) => {
return true;
};
const toggleListGroup = () => {
setIsExpanded((prevState) => !prevState);
};
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
const defaultState = projectState.projectStates?.find((state) => state.default);
let preloadedData: object = { state_id: defaultState?.id };
@@ -213,6 +214,10 @@ export const ListGroup = observer((props: Props) => {
handleOnDrop(source, destination);
highlightIssueOnDrop(getIssueBlockId(source.id, destination?.groupId), orderBy !== "sort_order");
if(!isExpanded){
handleCollapsedGroups(group.id)
}
},
})
);
@@ -248,7 +253,7 @@ export const ListGroup = observer((props: Props) => {
disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle}
addIssuesToView={addIssuesToView}
selectionHelpers={selectionHelpers}
toggleListGroup={toggleListGroup}
handleCollapsedGroups={handleCollapsedGroups}
/>
</Row>
{shouldExpand && (
@@ -1,15 +1,7 @@
"use client";
//ui
import {
ArrowDownWideNarrow,
ArrowUpNarrowWide,
CheckIcon,
ChevronDownIcon,
Eraser,
ListFilter,
MoveRight,
} from "lucide-react";
import { ArrowDownWideNarrow, ArrowUpNarrowWide, CheckIcon, ChevronDownIcon, Eraser, MoveRight } from "lucide-react";
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueOrderByOptions } from "@plane/types";
import { CustomMenu, Row } from "@plane/ui";
//hooks
@@ -59,7 +51,11 @@ export const HeaderColumn = (props: Props) => {
<div className="ml-3 flex">
{activeSortingProperty === property && (
<div className="flex h-3.5 w-3.5 items-center justify-center rounded-full">
<ListFilter className="h-3 w-3" />
{propertyDetails.ascendingOrderKey === displayFilters.order_by ? (
<ArrowDownWideNarrow className="h-3 w-3" />
) : (
<ArrowUpNarrowWide className="h-3 w-3" />
)}
</div>
)}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
@@ -280,6 +280,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
projectId={projectId}
disabled={!!data?.sourceIssueId}
handleFormChange={handleFormChange}
renderChevron
/>
)}
</div>
@@ -7,7 +7,6 @@ import {
CollaborativeDocumentReadOnlyEditorWithRef,
EditorReadOnlyRefApi,
EditorRefApi,
IMarking,
TAIMenuProps,
TDisplayConfig,
TRealtimeConfig,
@@ -19,7 +18,7 @@ import { IUserLite } from "@plane/types";
import { Row } from "@plane/ui";
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { cn, LIVE_URL } from "@/helpers/common.helper";
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
@@ -67,7 +66,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
project: { getProjectMemberIds },
} = useMember();
// derived values
const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : "";
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
const pageId = page?.id;
const pageTitle = page?.name ?? "";
const { isContentEditable, updateTitle, setIsSubmitting } = page;
@@ -113,19 +112,30 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
[]
);
const realtimeConfig: TRealtimeConfig = useMemo(
() => ({
url: `${LIVE_URL}/collaboration`,
queryParams: {
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
documentType: "project_page",
},
}),
[projectId, workspaceSlug]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
// Construct the WebSocket Collaboration URL
try {
const LIVE_SERVER_BASE_URL = LIVE_BASE_URL?.trim() || window.location.origin;
const WS_LIVE_URL = new URL(LIVE_SERVER_BASE_URL);
const isSecureEnvironment = window.location.protocol === "https:";
WS_LIVE_URL.protocol = isSecureEnvironment ? "wss" : "ws";
WS_LIVE_URL.pathname = `${LIVE_BASE_PATH}/collaboration`;
// Construct realtime config
return {
url: WS_LIVE_URL.toString(),
queryParams: {
workspaceSlug: workspaceSlug?.toString(),
projectId: projectId?.toString(),
documentType: "project_page",
},
};
} catch (error) {
console.error("Error creating realtime config", error);
return undefined;
}
}, [projectId, workspaceSlug]);
if (pageId === undefined) return <PageContentLoader />;
if (pageId === undefined || !realtimeConfig) return <PageContentLoader />;
return (
<div className="flex items-center h-full w-full overflow-y-auto">
+1 -1
View File
@@ -60,7 +60,7 @@ export const CYCLE_STATUS: {
{
label: "day left",
value: "current",
title: "Active",
title: "In progress",
color: "#F59E0B",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
+44 -9
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/nextjs";
import set from "lodash/set";
// plane
import { EIssueGroupBYServerToProperty } from "@plane/constants";
@@ -15,7 +16,7 @@ import { loadWorkSpaceData } from "./utils/load-workspace";
import { issueFilterCountQueryConstructor, issueFilterQueryConstructor } from "./utils/query-constructor";
import { runQuery } from "./utils/query-executor";
import { createTables } from "./utils/tables";
import { logError, getGroupedIssueResults, getSubGroupedIssueResults, logInfo, log } from "./utils/utils";
import { getGroupedIssueResults, getSubGroupedIssueResults, log, logError, logInfo } from "./utils/utils";
declare module "@sqlite.org/sqlite-wasm" {
export function sqlite3Worker1Promiser(...args: any): any;
@@ -67,7 +68,7 @@ export class Storage {
this.reset();
}
try {
await this._initialize(workspaceSlug);
await Sentry.startSpan({ name: "INIT_DB" }, async () => await this._initialize(workspaceSlug));
return true;
} catch (err) {
logError(err);
@@ -148,7 +149,9 @@ export class Storage {
syncWorkspace = async () => {
if (document.hidden || !rootStore.user.localDBEnabled) return; // return if the window gets hidden
loadWorkSpaceData(this.workspaceSlug);
await Sentry.startSpan({ name: "LOAD_WS", attributes: { slug: this.workspaceSlug } }, async () => {
await loadWorkSpaceData(this.workspaceSlug);
});
};
syncProject = async (projectId: string) => {
@@ -173,7 +176,7 @@ export class Storage {
if (document.hidden || !rootStore.user.localDBEnabled) return false; // return if the window gets hidden
try {
const sync = this._syncIssues(projectId);
const sync = Sentry.startSpan({ name: `SYNC_ISSUES` }, () => this._syncIssues(projectId));
this.setSync(projectId, sync);
await sync;
} catch (e) {
@@ -183,6 +186,8 @@ export class Storage {
};
_syncIssues = async (projectId: string) => {
const activeSpan = Sentry.getActiveSpan();
log("### Sync started");
let status = this.getStatus(projectId);
if (status === "loading" || status === "syncing") {
@@ -242,6 +247,11 @@ export class Storage {
this.setOption(projectId, "ready");
this.setStatus(projectId, "ready");
this.setSync(projectId, undefined);
activeSpan?.setAttributes({
projectId: projectId,
count: response.total_count,
});
};
getIssueCount = async (projectId: string) => {
@@ -279,7 +289,9 @@ export class Storage {
currentProjectStatus === "error" ||
!rootStore.user.localDBEnabled
) {
logInfo(`Project ${projectId} is loading, falling back to server`);
if (rootStore.user.localDBEnabled) {
logInfo(`Project ${projectId} is loading, falling back to server`);
}
const issueService = new IssueService();
return await issueService.getIssuesFromServer(workspaceSlug, projectId, queries);
}
@@ -289,7 +301,15 @@ export class Storage {
const query = issueFilterQueryConstructor(this.workspaceSlug, projectId, queries);
const countQuery = issueFilterCountQueryConstructor(this.workspaceSlug, projectId, queries);
const start = performance.now();
const [issuesRaw, count] = await Promise.all([runQuery(query), runQuery(countQuery)]);
let issuesRaw: any[] = [];
let count: any[];
try {
[issuesRaw, count] = await Promise.all([runQuery(query), runQuery(countQuery)]);
} catch (e) {
logError(e);
const issueService = new IssueService();
return await issueService.getIssuesFromServer(workspaceSlug, projectId, queries);
}
// const issuesRaw = await runQuery(query);
const end = performance.now();
@@ -318,6 +338,8 @@ export class Storage {
issueResults = getGroupedIssueResults(issueResults);
}
}
const groupCount = group_by ? Object.keys(issueResults).length : undefined;
// const subGroupCount = sub_group_by ? Object.keys(issueResults[Object.keys(issueResults)[0]]).length : undefined;
const groupingEnd = performance.now();
const times = {
@@ -326,8 +348,9 @@ export class Storage {
Grouping: groupingEnd - grouping,
};
log(issueResults);
console.table(times);
if ((window as any).DEBUG) {
console.table(times);
}
const total_pages = Math.ceil(total_count / Number(pageSize));
const next_page_results = total_pages > parseInt(page) + 1;
@@ -341,6 +364,17 @@ export class Storage {
total_pages,
};
const activeSpan = Sentry.getActiveSpan();
activeSpan?.setAttributes({
projectId,
count: total_count,
groupBy: group_by,
subGroupBy: sub_group_by,
queries: queries,
local: true,
groupCount,
// subGroupCount,
});
return out;
};
@@ -353,6 +387,7 @@ export class Storage {
return formatLocalIssue(issues[0]);
}
} catch (err) {
logError(err);
console.warn("unable to fetch issue from local db");
}
@@ -415,5 +450,5 @@ export const formatLocalIssue = (issue: any) => {
ARRAY_FIELDS.forEach((field: string) => {
currIssue[field] = currIssue[field] ? JSON.parse(currIssue[field]) : [];
});
return currIssue as TIssue;
return currIssue as TIssue & { group_id?: string; total_issues: number; sub_group_id?: string };
};
+1 -1
View File
@@ -330,7 +330,7 @@ const getSingleFilterFields = (queries: any) => {
export const getIssueFieldsFragment = () => {
const { description_html, ...filtered } = issueSchema;
const keys = Object.keys(filtered);
const sql = ` ${keys.map((key, index) => `i.${key}`).join(`,
const sql = ` ${keys.map((key) => `i.${key}`).join(`,
`)}`;
return sql;
};
+15 -3
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/nextjs";
import pick from "lodash/pick";
import { TIssue } from "@plane/types";
import { rootStore } from "@/lib/store-context";
@@ -9,7 +10,13 @@ export const log = (...args: any) => {
console.log(...args);
}
};
export const logError = console.error;
export const logError = (e: any) => {
if (e?.result?.errorClass === "SQLite3Error") {
e = parseSQLite3Error(e);
}
Sentry.captureException(e);
console.log(e);
};
export const logInfo = console.info;
export const updatePersistentLayer = async (issueIds: string | string[]) => {
@@ -66,7 +73,7 @@ export const wrapDateTime = (field: string) => {
return field;
};
export const getGroupedIssueResults = (issueResults: (TIssue & { group_id: string; total_issues: number })[]): any => {
export const getGroupedIssueResults = (issueResults: (TIssue & { group_id?: string; total_issues: number })[]): any => {
const groupedResults: {
[key: string]: {
results: TIssue[];
@@ -88,7 +95,7 @@ export const getGroupedIssueResults = (issueResults: (TIssue & { group_id: strin
};
export const getSubGroupedIssueResults = (
issueResults: (TIssue & { group_id: string; total_issues: number; sub_group_id: string })[]
issueResults: (TIssue & { group_id?: string; total_issues: number; sub_group_id?: string })[]
): any => {
const subGroupedResults: {
[key: string]: {
@@ -140,3 +147,8 @@ export const getSubGroupedIssueResults = (
};
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const parseSQLite3Error = (error: any) => {
error.result = JSON.stringify(error.result);
return error;
};
+12
View File
@@ -43,6 +43,18 @@ export class CycleService extends APIService {
});
}
async workspaceActiveCyclesProgressPro(
workspaceSlug: string,
projectId: string,
cycleId: string
): Promise<TProgressSnapshot> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-progress/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async workspaceActiveCycles(
workspaceSlug: string,
cursor: string,
+6 -9
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/nextjs";
// types
import type {
IIssueDisplayProperties,
@@ -54,14 +55,7 @@ export class IssueService extends APIService {
queries?: any,
config = {}
): Promise<TIssuesResponse> {
queries.project_id = projectId;
return this.get(
`/api/workspaces/${workspaceSlug}/v2/issues/`,
{
params: queries,
},
config
)
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/v2/issues/`, { params: queries }, config)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -69,7 +63,10 @@ export class IssueService extends APIService {
}
async getIssues(workspaceSlug: string, projectId: string, queries?: any, config = {}): Promise<TIssuesResponse> {
const response = await persistence.getIssues(workspaceSlug, projectId, queries, config);
const response = await Sentry.startSpan({ name: "GET_ISSUES" }, async () => {
const res = await persistence.getIssues(workspaceSlug, projectId, queries, config);
return res;
});
return response as TIssuesResponse;
}
+62 -9
View File
@@ -11,9 +11,11 @@ import {
TProgressSnapshot,
TCycleEstimateDistribution,
TCycleDistribution,
TCycleEstimateType,
TCycleProgress,
} from "@plane/types";
// helpers
import { orderCycles, shouldFilterCycle } from "@/helpers/cycle.helper";
import { orderCycles, shouldFilterCycle, formatActiveCycle } from "@/helpers/cycle.helper";
import { getDate } from "@/helpers/date-time.helper";
import { DistributionUpdates, updateDistribution } from "@/helpers/distribution-update.helper";
// services
@@ -27,11 +29,14 @@ import { CoreRootStore } from "./root.store";
export interface ICycleStore {
// loaders
loader: boolean;
progressLoader: boolean;
// observables
fetchedMap: Record<string, boolean>;
cycleMap: Record<string, ICycle>;
plotType: Record<string, TCyclePlotType>;
estimatedType: Record<string, TCycleEstimateType>;
activeCycleIdMap: Record<string, boolean>;
// computed
currentProjectCycleIds: string[] | null;
currentProjectCompletedCycleIds: string[] | null;
@@ -43,6 +48,7 @@ export interface ICycleStore {
currentProjectActiveCycle: ICycle | null;
// computed actions
getActiveCycleProgress: (cycleId?: string) => { cycle: ICycle; isBurnDown: boolean; isTypeIssue: boolean } | null;
getFilteredCycleIds: (projectId: string, sortByManual: boolean) => string[] | null;
getFilteredCompletedCycleIds: (projectId: string) => string[] | null;
getFilteredArchivedCycleIds: (projectId: string) => string[] | null;
@@ -51,10 +57,12 @@ export interface ICycleStore {
getActiveCycleById: (cycleId: string) => ICycle | null;
getProjectCycleIds: (projectId: string) => string[] | null;
getPlotTypeByCycleId: (cycleId: string) => TCyclePlotType;
getEstimateTypeByCycleId: (cycleId: string) => TCycleEstimateType;
// actions
updateCycleDistribution: (distributionUpdates: DistributionUpdates, cycleId: string) => void;
validateDate: (workspaceSlug: string, projectId: string, payload: CycleDateCheckData) => Promise<any>;
setPlotType: (cycleId: string, plotType: TCyclePlotType) => void;
setEstimateType: (cycleId: string, estimateType: TCycleEstimateType) => void;
// fetch
fetchWorkspaceCycles: (workspaceSlug: string) => Promise<ICycle[]>;
fetchAllCycles: (workspaceSlug: string, projectId: string) => Promise<undefined | ICycle[]>;
@@ -63,6 +71,7 @@ export interface ICycleStore {
fetchArchivedCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
fetchCycleDetails: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<ICycle>;
fetchActiveCycleProgress: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<TProgressSnapshot>;
fetchActiveCycleProgressPro: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<void>;
fetchActiveCycleAnalytics: (
workspaceSlug: string,
projectId: string,
@@ -89,8 +98,10 @@ export interface ICycleStore {
export class CycleStore implements ICycleStore {
// observables
loader: boolean = false;
progressLoader: boolean = false;
cycleMap: Record<string, ICycle> = {};
plotType: Record<string, TCyclePlotType> = {};
estimatedType: Record<string, TCycleEstimateType> = {};
activeCycleIdMap: Record<string, boolean> = {};
//loaders
fetchedMap: Record<string, boolean> = {};
@@ -106,8 +117,10 @@ export class CycleStore implements ICycleStore {
makeObservable(this, {
// observables
loader: observable.ref,
progressLoader: observable,
cycleMap: observable,
plotType: observable,
estimatedType: observable,
activeCycleIdMap: observable,
fetchedMap: observable,
// computed
@@ -122,6 +135,7 @@ export class CycleStore implements ICycleStore {
// actions
setPlotType: action,
setEstimateType: action,
fetchWorkspaceCycles: action,
fetchAllCycles: action,
fetchActiveCycle: action,
@@ -258,6 +272,19 @@ export class CycleStore implements ICycleStore {
return this.cycleMap?.[this.currentProjectActiveCycleId!] ?? null;
}
/**
* returns active cycle progress for a project
*/
getActiveCycleProgress = computedFn((cycleId?: string) => {
const cycle = cycleId ? this.cycleMap[cycleId] : this.currentProjectActiveCycle;
if (!cycle) return null;
const isTypeIssue = this.getEstimateTypeByCycleId(cycle.id) === "issues";
const isBurnDown = this.getPlotTypeByCycleId(cycle.id) === "burndown";
return { cycle, isTypeIssue, isBurnDown };
});
/**
* @description returns filtered cycle ids based on display filters and filters
* @param {TCycleDisplayFilters} displayFilters
@@ -371,25 +398,39 @@ export class CycleStore implements ICycleStore {
await this.cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
/**
* @description gets the plot type for the module store
* @description gets the plot type for the cycle store
* @param {TCyclePlotType} plotType
*/
getPlotTypeByCycleId = (cycleId: string) => {
getPlotTypeByCycleId = computedFn((cycleId: string) => this.plotType[cycleId] || "burndown");
/**
* @description gets the estimate type for the cycle store
* @param {TCycleEstimateType} estimateType
*/
getEstimateTypeByCycleId = computedFn((cycleId: string) => {
const { projectId } = this.rootStore.router;
return projectId && this.rootStore.projectEstimate.areEstimateEnabledByProjectId(projectId)
? this.plotType[cycleId] || "burndown"
: "burndown";
};
? this.estimatedType[cycleId] || "issues"
: "issues";
});
/**
* @description updates the plot type for the module store
* @description updates the plot type for the cycle store
* @param {TCyclePlotType} plotType
*/
setPlotType = (cycleId: string, plotType: TCyclePlotType) => {
set(this.plotType, [cycleId], plotType);
};
/**
* @description updates the estimate type for the cycle store
* @param {TCycleEstimateType} estimateType
*/
setEstimateType = (cycleId: string, estimateType: TCycleEstimateType) => {
set(this.estimatedType, [cycleId], estimateType);
};
/**
* @description fetch all cycles
* @param workspaceSlug
@@ -481,13 +522,25 @@ export class CycleStore implements ICycleStore {
* @param cycleId
* @returns
*/
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) =>
await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
fetchActiveCycleProgress = async (workspaceSlug: string, projectId: string, cycleId: string) => {
this.progressLoader = true;
return await this.cycleService.workspaceActiveCyclesProgress(workspaceSlug, projectId, cycleId).then((progress) => {
runInAction(() => {
set(this.cycleMap, [cycleId], { ...this.cycleMap[cycleId], ...progress });
this.progressLoader = false;
});
return progress;
});
};
/**
* @description fetches active cycle progress for pro users
* @param workspaceSlug
* @param projectId
* @param cycleId
* @returns
*/
fetchActiveCycleProgressPro = action(async (workspaceSlug: string, projectId: string, cycleId: string) => {});
/**
* @description fetches active cycle analytics
@@ -254,7 +254,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, {
kanban_filters: _filters.kanbanFilters,
});
+1 -1
View File
@@ -278,7 +278,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, cycleId, currentUserId, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.CYCLE, type, workspaceSlug, cycleId, currentUserId, {
kanban_filters: _filters.kanbanFilters,
});
@@ -139,6 +139,10 @@ export class CycleIssues extends BaseIssuesStore implements ICycleIssues {
const cycleId = id ?? this.cycleId;
projectId && cycleId && this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId);
// fetch cycle progress
projectId &&
cycleId &&
this.rootIssueStore.rootStore.cycle.fetchActiveCycleProgressPro(workspaceSlug, projectId, cycleId);
};
updateParentStats = (prevIssueState?: TIssue, nextIssueState?: TIssue, id?: string | undefined) => {
+1 -1
View File
@@ -250,7 +250,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, projectId, undefined, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, {
kanban_filters: _filters.kanbanFilters,
});
@@ -248,7 +248,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore {
[filterType]: filters[filterType],
},
};
// All group_by "filters" are stored in a single array, will cause inconsistency in case of duplicated values
storage.set("issue_local_filters", JSON.stringify(storageFilters));
},
};
+1 -1
View File
@@ -281,7 +281,7 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, moduleId, currentUserId, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.MODULE, type, workspaceSlug, moduleId, currentUserId, {
kanban_filters: _filters.kanbanFilters,
});
+1 -1
View File
@@ -252,7 +252,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, userId, undefined, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, {
kanban_filters: _filters.kanbanFilters,
});
@@ -264,7 +264,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, viewId, currentUserId, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT_VIEW, type, workspaceSlug, viewId, currentUserId, {
kanban_filters: _filters.kanbanFilters,
});
@@ -285,7 +285,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo
const currentUserId = this.rootIssueStore.currentUserId;
if (currentUserId)
this.handleIssuesLocalFilters.set(EIssuesStoreType.PROJECT, type, workspaceSlug, undefined, viewId, {
this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, {
kanban_filters: _filters.kanbanFilters,
});
@@ -0,0 +1 @@
export * from "ce/components/cycles/active-cycle";
@@ -0,0 +1 @@
export * from "ce/components/cycles/analytics-sidebar";
+2
View File
@@ -0,0 +1,2 @@
export * from "./active-cycle";
export * from "./analytics-sidebar";
+1 -1
View File
@@ -9,7 +9,7 @@ export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = `${LIVE_BASE_URL}${LIVE_BASE_PATH}`;
+111 -1
View File
@@ -1,9 +1,24 @@
import { startOfToday, format } from "date-fns";
import { isEmpty, orderBy, uniqBy } from "lodash";
import sortBy from "lodash/sortBy";
import { ICycle, TCycleFilters } from "@plane/types";
// helpers
import { getDate } from "@/helpers/date-time.helper";
import { findTotalDaysInRange, generateDateArray, getDate } from "@/helpers/date-time.helper";
import { satisfiesDateFilter } from "@/helpers/filter.helper";
export type TProgressChartData = {
date: string;
scope: number;
completed: number;
backlog: number;
started: number;
unstarted: number;
cancelled: number;
pending: number;
ideal: number;
actual: number;
}[];
/**
* @description orders cycles based on their status
* @param {ICycle[]} cycles
@@ -60,3 +75,98 @@ export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean
return fallsInFilters;
};
const scope = (p: any, isTypeIssue: boolean) => (isTypeIssue ? p.total_issues : p.total_estimate_points);
const ideal = (date: string, scope: number, cycle: ICycle) =>
Math.floor(
((findTotalDaysInRange(date, cycle.end_date) || 0) /
(findTotalDaysInRange(cycle.start_date, cycle.end_date) || 0)) *
scope
);
const formatV1Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
const today = format(startOfToday(), "yyyy-MM-dd");
const data = isTypeIssue ? cycle.distribution : cycle.estimate_distribution;
const extendedArray = generateDateArray(endDate, endDate).map((d) => d.date);
if (isEmpty(data)) return [];
const progress = [...Object.keys(data.completion_chart), ...extendedArray].map((p) => {
const pending = data.completion_chart[p] || 0;
const total = isTypeIssue ? cycle.total_issues : cycle.total_estimate_points;
const completed = scope(cycle, isTypeIssue) - pending;
return {
date: p,
scope: p! < today ? scope(cycle, isTypeIssue) : null,
completed,
backlog: isTypeIssue ? cycle.backlog_issues : cycle.backlog_estimate_points,
started: p === today ? cycle[isTypeIssue ? "started_issues" : "started_estimate_points"] : undefined,
unstarted: p === today ? cycle[isTypeIssue ? "unstarted_issues" : "unstarted_estimate_points"] : undefined,
cancelled: p === today ? cycle[isTypeIssue ? "cancelled_issues" : "cancelled_estimate_points"] : undefined,
pending: Math.abs(pending || 0),
ideal:
p < today
? ideal(p, total || 0, cycle)
: p <= cycle.end_date!
? ideal(today as string, total || 0, cycle)
: null,
actual: p <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
};
});
return progress;
};
const formatV2Data = (isTypeIssue: boolean, cycle: ICycle, isBurnDown: boolean, endDate: Date | string) => {
if (!cycle.progress) return [];
let today: Date | string = startOfToday();
const extendedArray = endDate > today ? generateDateArray(today as Date, endDate) : [];
if (isEmpty(cycle.progress)) return extendedArray;
today = format(startOfToday(), "yyyy-MM-dd");
const todaysData = cycle?.progress[cycle?.progress.length - 1];
const scopeToday = scope(todaysData, isTypeIssue);
const idealToday = ideal(todaysData.date, scopeToday, cycle);
let progress = [...orderBy(cycle?.progress, "date"), ...extendedArray].map((p) => {
const pending = isTypeIssue
? p.total_issues - p.completed_issues - p.cancelled_issues
: p.total_estimate_points - p.completed_estimate_points - p.cancelled_estimate_points;
const completed = isTypeIssue ? p.completed_issues : p.completed_estimate_points;
const dataDate = p.progress_date ? format(new Date(p.progress_date), "yyyy-MM-dd") : p.date;
return {
date: dataDate,
scope: dataDate! < today ? scope(p, isTypeIssue) : dataDate! <= cycle.end_date! ? scopeToday : null,
completed,
backlog: isTypeIssue ? p.backlog_issues : p.backlog_estimate_points,
started: isTypeIssue ? p.started_issues : p.started_estimate_points,
unstarted: isTypeIssue ? p.unstarted_issues : p.unstarted_estimate_points,
cancelled: isTypeIssue ? p.cancelled_issues : p.cancelled_estimate_points,
pending: Math.abs(pending),
ideal:
dataDate! < today
? ideal(dataDate, scope(p, isTypeIssue), cycle)
: dataDate! < cycle.end_date!
? idealToday
: null,
actual: dataDate! <= today ? (isBurnDown ? Math.abs(pending) : completed) : undefined,
};
});
progress = uniqBy(progress, "date");
return progress;
};
export const formatActiveCycle = (args: {
cycle: ICycle;
isBurnDown?: boolean | undefined;
isTypeIssue?: boolean | undefined;
}) => {
const { cycle, isBurnDown, isTypeIssue } = args;
const endDate: Date | string = new Date(cycle.end_date!);
return cycle.version === 1
? formatV1Data(isTypeIssue!, cycle, isBurnDown!, endDate)
: formatV2Data(isTypeIssue!, cycle, isBurnDown!, endDate);
};
+29
View File
@@ -357,3 +357,32 @@ export const getReadTimeFromWordsCount = (wordsCount: number): number => {
const minutes = wordsCount / wordsPerMinute;
return minutes * 60;
};
/**
* @description generates an array of dates between the start and end dates
* @param startDate
* @param endDate
* @returns
*/
export const generateDateArray = (startDate: string | Date, endDate: string | Date) => {
// Convert the start and end dates to Date objects if they aren't already
const start = new Date(startDate);
// start.setDate(start.getDate() + 1);
const end = new Date(endDate);
end.setDate(end.getDate() + 1);
// Create an empty array to store the dates
const dateArray = [];
// Use a while loop to generate dates between the range
while (start <= end) {
// Increment the date by 1 day (86400000 milliseconds)
start.setDate(start.getDate() + 1);
// Push the current date (converted to ISO string for consistency)
dateArray.push({
date: new Date(start).toISOString().split("T")[0],
});
}
return dateArray;
};
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.22.0",
"version": "0.23.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -65,7 +65,8 @@
"use-debounce": "^9.0.4",
"use-font-face-observer": "^1.2.2",
"uuid": "^9.0.0",
"zxcvbn": "^4.4.2"
"zxcvbn": "^4.4.2",
"recharts": "^2.12.7"
},
"devDependencies": {
"@plane/eslint-config": "*",
+1694 -1466
View File
File diff suppressed because it is too large Load Diff