Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc1b79d81 | ||
|
|
4a6f646317 | ||
|
|
b8e21d92bf | ||
|
|
b87d5c5be6 | ||
|
|
ceda06e88d | ||
|
|
eb344881c2 | ||
|
|
01257a6936 | ||
|
|
51b01ebcac | ||
|
|
0a8d66dcc3 | ||
|
|
ec22f1fc53 | ||
|
|
a5e3e4fe7d | ||
|
|
f1a0a8d925 | ||
|
|
ee0dce46de | ||
|
|
b7ee7e19fc | ||
|
|
8291043704 | ||
|
|
bc41b1113a | ||
|
|
77d4a8379d | ||
|
|
c90df623de | ||
|
|
62c45f3bb1 | ||
|
|
96dc9db237 | ||
|
|
5474ab326d | ||
|
|
4940dc2193 | ||
|
|
632282d0df | ||
|
|
33f6c1fe9e | ||
|
|
927d265209 |
+1
-1
@@ -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
@@ -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 community’s 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 user’s 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.22.0"
|
||||
"version": "0.23.0"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
"next.js",
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
Vendored
+18
-3
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
|
||||
+2
-2
@@ -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";
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./active-cycle";
|
||||
export * from "./analytics-sidebar";
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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": "*",
|
||||
|
||||
Reference in New Issue
Block a user