Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging

This commit is contained in:
pablohashescobar 2024-03-11 21:09:41 +05:30
commit 171b664fe7
115 changed files with 2362 additions and 757 deletions

View File

@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db"
POSTGRES_DB="plane" POSTGRES_DB="plane"
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB} DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings # Redis Settings
REDIS_HOST="plane-redis" REDIS_HOST="plane-redis"
@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit # Maximum file upload limit
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
# GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker # Settings related to Docker
DOCKERIZED=1 # deprecated DOCKERIZED=1 # deprecated
@ -48,16 +39,6 @@ USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# SignUps
ENABLE_SIGNUP="1"
# Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1"
# Enable Magic link Login
ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings # Email redirections and minio domain settings
WEB_URL="http://localhost" WEB_URL="http://localhost"

View File

@ -22,10 +22,14 @@ export MACHINE_SIGNATURE=$SIGNATURE
# Register instance # Register instance
python manage.py register_instance "$MACHINE_SIGNATURE" python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable # Load the configuration variable
python manage.py configure_instance python manage.py configure_instance
# Create the default bucket # Create the default bucket
python manage.py create_bucket python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile - exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256
export MACHINE_SIGNATURE=$SIGNATURE export MACHINE_SIGNATURE=$SIGNATURE
# Register instance # Register instance
python manage.py register_instance $MACHINE_SIGNATURE python manage.py register_instance "$MACHINE_SIGNATURE"
# Load the configuration variable # Load the configuration variable
python manage.py configure_instance python manage.py configure_instance
# Create the default bucket # Create the default bucket
python manage.py create_bucket python manage.py create_bucket
# Clear Cache before starting to remove stale values
python manage.py clear_cache
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local

View File

@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer):
class ModuleDetailSerializer(ModuleSerializer): class ModuleDetailSerializer(ModuleSerializer):
link_module = ModuleLinkSerializer(read_only=True, many=True) link_module = ModuleLinkSerializer(read_only=True, many=True)
sub_issues = serializers.IntegerField(read_only=True)
class Meta(ModuleSerializer.Meta): class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + ["link_module"] fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"]
class ModuleFavoriteSerializer(BaseSerializer): class ModuleFavoriteSerializer(BaseSerializer):

View File

@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer):
class ProjectListSerializer(DynamicBaseSerializer): class ProjectListSerializer(DynamicBaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
archived_issues = serializers.IntegerField(read_only=True)
archived_sub_issues = serializers.IntegerField(read_only=True)
draft_issues = serializers.IntegerField(read_only=True)
draft_sub_issues = serializers.IntegerField(read_only=True)
sub_issues = serializers.IntegerField(read_only=True)
is_favorite = serializers.BooleanField(read_only=True) is_favorite = serializers.BooleanField(read_only=True)
total_members = serializers.IntegerField(read_only=True) total_members = serializers.IntegerField(read_only=True)
total_cycles = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True)

View File

@ -106,15 +106,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
) )
.annotate(is_favorite=Exists(favorite_subquery)) .annotate(is_favorite=Exists(favorite_subquery))
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__state__group",
@ -232,7 +223,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -327,14 +317,14 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
} }
if data[0]["start_date"] and data[0]["end_date"]: if data[0]["start_date"] and data[0]["end_date"]:
data[0]["distribution"][ data[0]["distribution"]["completion_chart"] = (
"completion_chart" burndown_plot(
] = burndown_plot(
queryset=queryset.first(), queryset=queryset.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
cycle_id=data[0]["id"], cycle_id=data[0]["id"],
) )
)
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)
@ -356,7 +346,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -402,7 +391,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -474,7 +462,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -487,10 +474,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk) queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
)
)
)
data = ( data = (
self.get_queryset() self.get_queryset()
.filter(pk=pk) .filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=True,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_cycle__cycle_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.values( .values(
# necessary fields # necessary fields
"id", "id",
@ -507,6 +526,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_source", "external_source",
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
"sub_issues",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues", "total_issues",

View File

@ -3,7 +3,7 @@ import json
# Django Imports # Django Imports
from django.utils import timezone from django.utils import timezone
from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q from django.db.models import Prefetch, F, OuterRef, Exists, Count, Q, Func
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField from django.db.models import Value, UUIDField
@ -79,15 +79,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
), ),
) )
) )
.annotate(
total_issues=Count(
"issue_module",
filter=Q(
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
),
)
.annotate( .annotate(
completed_issues=Count( completed_issues=Count(
"issue_module__issue__state__group", "issue_module__issue__state__group",
@ -183,7 +174,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id", "external_id",
# computed fields # computed fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -225,7 +215,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id", "external_id",
# computed fields # computed fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -237,7 +226,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(pk=pk) queryset = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=True,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"),
parent__isnull=False,
issue_module__module_id=pk,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
assignee_distribution = ( assignee_distribution = (
Issue.objects.filter( Issue.objects.filter(
@ -380,7 +392,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"external_id", "external_id",
# computed fields # computed fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",

View File

@ -46,9 +46,11 @@ from plane.db.models import (
Inbox, Inbox,
ProjectDeployBoard, ProjectDeployBoard,
IssueProperty, IssueProperty,
Issue,
) )
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
class ProjectViewSet(WebhookMixin, BaseViewSet): class ProjectViewSet(WebhookMixin, BaseViewSet):
serializer_class = ProjectListSerializer serializer_class = ProjectListSerializer
model = Project model = Project
@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
).data ).data
return Response(projects, status=status.HTTP_200_OK) return Response(projects, status=status.HTTP_200_OK)
def retrieve(self, request, slug, pk):
project = (
self.get_queryset()
.filter(pk=pk)
.annotate(
total_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("pk"),
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
archived_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
archived_at__isnull=False,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=True,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
draft_sub_issues=Issue.objects.filter(
project_id=self.kwargs.get("pk"),
is_draft=True,
parent__isnull=False,
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug): def create(self, request, slug):
try: try:
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
] ]
# Cache the below api for 24 hours # Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False) @cache_response(60 * 60 * 24, user=False)
def get(self, request): def get(self, request):

View File

@ -0,0 +1,17 @@
# Django imports
from django.core.cache import cache
from django.core.management import BaseCommand
class Command(BaseCommand):
help = "Clear Cache before starting the server to remove stale values"
def handle(self, *args, **options):
try:
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache Cleared"))
return
except Exception:
# Another ClientError occurred
self.stdout.write(self.style.ERROR("Failed to clear cache"))
return

View File

@ -1,7 +1,11 @@
from django.core.cache import cache # Python imports
# from django.utils.encoding import force_bytes
# import hashlib
from functools import wraps from functools import wraps
# Django imports
from django.conf import settings
from django.core.cache import cache
# Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -22,21 +26,20 @@ def cache_response(timeout=60 * 60, path=None, user=True):
def _wrapped_view(instance, request, *args, **kwargs): def _wrapped_view(instance, request, *args, **kwargs):
# Function to generate cache key # Function to generate cache key
auth_header = ( auth_header = (
None if request.user.is_anonymous else str(request.user.id) if user else None None
if request.user.is_anonymous
else str(request.user.id) if user else None
) )
custom_path = path if path is not None else request.get_full_path() custom_path = path if path is not None else request.get_full_path()
key = generate_cache_key(custom_path, auth_header) key = generate_cache_key(custom_path, auth_header)
cached_result = cache.get(key) cached_result = cache.get(key)
if cached_result is not None: if cached_result is not None:
print("Cache Hit")
return Response( return Response(
cached_result["data"], status=cached_result["status"] cached_result["data"], status=cached_result["status"]
) )
print("Cache Miss")
response = view_func(instance, request, *args, **kwargs) response = view_func(instance, request, *args, **kwargs)
if response.status_code == 200: if response.status_code == 200 and not settings.DEBUG:
cache.set( cache.set(
key, key,
{"data": response.data, "status": response.status_code}, {"data": response.data, "status": response.status_code},
@ -71,11 +74,12 @@ def invalidate_cache(path=None, url_params=False, user=True):
) )
auth_header = ( auth_header = (
None if request.user.is_anonymous else str(request.user.id) if user else None None
if request.user.is_anonymous
else str(request.user.id) if user else None
) )
key = generate_cache_key(custom_path, auth_header) key = generate_cache_key(custom_path, auth_header)
cache.delete(key) cache.delete(key)
print("Invalidating cache")
# Execute the view function # Execute the view function
return view_func(instance, request, *args, **kwargs) return view_func(instance, request, *args, **kwargs)

View File

@ -5,14 +5,8 @@ x-app-env: &app-env
- NGINX_PORT=${NGINX_PORT:-80} - NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost} - WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated
- SENTRY_DSN=${SENTRY_DSN:-""} - SENTRY_DSN=${SENTRY_DSN:-""}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} # deprecated
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
# Gunicorn Workers # Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
@ -28,20 +22,6 @@ x-app-env: &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
# EMAIL SETTINGS - Deprecated can be configured through admin panel
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
# Application secret # Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS # DATA STORE SETTINGS

View File

@ -7,13 +7,8 @@ API_REPLICAS=1
NGINX_PORT=80 NGINX_PORT=80
WEB_URL=http://localhost WEB_URL=http://localhost
DEBUG=0 DEBUG=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN= SENTRY_DSN=
SENTRY_ENVIRONMENT=production SENTRY_ENVIRONMENT=production
GOOGLE_CLIENT_ID=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS=http://localhost CORS_ALLOWED_ORIGINS=http://localhost
#DB SETTINGS #DB SETTINGS
@ -30,19 +25,7 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/ REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS # Secret Key
EMAIL_HOST=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=587
EMAIL_FROM=Team Plane <team@mailer.plane.so>
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
ENABLE_MAGIC_LINK_LOGIN=0
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS # DATA STORE SETTINGS

View File

@ -1,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx"; import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
interface EditorClassNames { interface EditorClassNames {
@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// Helper function to find the parent node of a specific type
export function findParentNodeOfType(selection: Selection, typeName: string) {
let depth = selection.$anchor.depth;
while (depth > 0) {
const node = selection.$anchor.node(depth);
if (node.type.name === typeName) {
return { node, pos: selection.$anchor.start(depth) - 1 };
}
depth--;
}
return null;
}
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") { while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode; node = node.parentNode;

View File

@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react"; import { Editor } from "@tiptap/react";
import { ReactNode } from "react"; import { FC, ReactNode } from "react";
interface EditorContainerProps { interface EditorContainerProps {
editor: Editor | null; editor: Editor | null;
@ -8,12 +8,48 @@ interface EditorContainerProps {
hideDragHandle?: () => void; hideDragHandle?: () => void;
} }
export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => ( export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorClassNames, hideDragHandle, children } = props;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
if (editor.isFocused) return; // If editor is already focused, do nothing
const { selection } = editor.state;
const currentNode = selection.$from.node();
editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end
if (
currentNode.content.size === 0 && // Check if the current node is empty
!(
editor.isActive("orderedList") ||
editor.isActive("bulletList") ||
editor.isActive("taskItem") ||
editor.isActive("table") ||
editor.isActive("blockquote") ||
editor.isActive("codeBlock")
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
) {
return;
}
// Insert a new paragraph at the end of the document
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
};
return (
<div <div
id="editor-container" id="editor-container"
onClick={() => { onClick={handleContainerClick}
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
onMouseLeave={() => { onMouseLeave={() => {
hideDragHandle?.(); hideDragHandle?.();
}} }}
@ -21,4 +57,5 @@ export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, chil
> >
{children} {children}
</div> </div>
); );
};

View File

@ -1,17 +1,28 @@
import { Editor, EditorContent } from "@tiptap/react"; import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react"; import { FC, ReactNode } from "react";
import { ImageResizer } from "src/ui/extensions/image/image-resize"; import { ImageResizer } from "src/ui/extensions/image/image-resize";
interface EditorContentProps { interface EditorContentProps {
editor: Editor | null; editor: Editor | null;
editorContentCustomClassNames: string | undefined; editorContentCustomClassNames: string | undefined;
children?: ReactNode; children?: ReactNode;
tabIndex?: number;
} }
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => ( export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
<div className={`contentEditor ${editorContentCustomClassNames}`}> const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
return (
<div
className={`contentEditor ${editorContentCustomClassNames}`}
tabIndex={tabIndex}
onFocus={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />} {editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children} {children}
</div> </div>
); );
};

View File

@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image";
import { DeleteImage } from "src/types/delete-image"; import { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image"; import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-image";
interface ImageNode extends ProseMirrorNode { interface ImageNode extends ProseMirrorNode {
attrs: { attrs: {
@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image";
export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) => export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) =>
ImageExt.extend({ ImageExt.extend({
addKeyboardShortcuts() {
return {
ArrowDown: insertLineBelowImageAction,
ArrowUp: insertLineAboveImageAction,
};
},
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
UploadImagesPlugin(cancelUploadImage), UploadImagesPlugin(cancelUploadImage),

View File

@ -0,0 +1,45 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
// Since we want to insert above the image, we use the imagePos directly
const insertPos = imagePos;
if (insertPos < 0) return false;
// Check for an existing node immediately before the image
if (insertPos === 0) {
// If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there
editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run();
editor.chain().setTextSelection(insertPos).run();
} else {
const prevNode = doc.nodeAt(insertPos);
if (prevNode && prevNode.type.name === "paragraph") {
// If the previous node is a paragraph, move the cursor there
editor.chain().setTextSelection(insertPos).run();
} else {
return false;
}
}
return true;
};

View File

@ -0,0 +1,46 @@
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { KeyboardShortcutCommand } from "@tiptap/core";
export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => {
const { selection, doc } = editor.state;
const { $from, $to } = selection;
let imageNode: ProseMirrorNode | null = null;
let imagePos: number | null = null;
// Check if the selection itself is an image node
doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.type.name === "image") {
imageNode = node;
imagePos = pos;
return false; // Stop iterating once an image node is found
}
return true;
});
if (imageNode === null || imagePos === null) return false;
const guaranteedImageNode: ProseMirrorNode = imageNode;
const nextNodePos = imagePos + guaranteedImageNode.nodeSize;
// Check for an existing node immediately after the image
const nextNode = doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is a paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
// If the next node is not a paragraph, do not proceed
return false;
}
return true;
};

View File

@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography"; import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule"; import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
export const CoreEditorExtensions = ( export const CoreEditorExtensions = (
mentionConfig: { mentionConfig: {
@ -66,7 +66,6 @@ export const CoreEditorExtensions = (
CustomQuoteExtension.configure({ CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}), }),
CustomHorizontalRule.configure({ CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" }, HTMLAttributes: { class: "mt-4 mb-4" },
}), }),

View File

@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls";
import { TableView } from "src/ui/extensions/table/table/table-view"; import { TableView } from "src/ui/extensions/table/table/table-view";
import { createTable } from "src/ui/extensions/table/table/utilities/create-table"; import { createTable } from "src/ui/extensions/table/table/utilities/create-table";
import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected"; import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected";
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
export interface TableOptions { export interface TableOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@ -231,6 +233,8 @@ export const Table = Node.create({
"Mod-Backspace": deleteTableWhenAllCellsSelected, "Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected, Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected, "Mod-Delete": deleteTableWhenAllCellsSelected,
ArrowDown: insertLineBelowTableAction,
ArrowUp: insertLineAboveTableAction,
}; };
}, },

View File

@ -0,0 +1,50 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
// Determine if the selection is in the first row of the table
const firstRow = tableNode.node.child(0);
const selectionPath = (selection.$anchor as any).path;
const selectionInFirstRow = selectionPath.includes(firstRow);
if (!selectionInFirstRow) return false;
// Check if the table is at the very start of the document or its parent node
if (tablePos === 0) {
// The table is at the start, so just insert a paragraph at the current position
editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(tablePos + 1)
.run();
} else {
// The table is not at the start, check for the node immediately before the table
const prevNodePos = tablePos - 1;
if (prevNodePos <= 0) return false;
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
if (prevNode && prevNode.type.name === "paragraph") {
// If there's a paragraph before the table, move the cursor to the end of that paragraph
const endOfParagraphPos = tablePos - prevNode.nodeSize;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else {
return false;
}
}
return true;
};

View File

@ -0,0 +1,48 @@
import { KeyboardShortcutCommand } from "@tiptap/core";
import { findParentNodeOfType } from "src/lib/utils";
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
// Check if the current selection or the closest node is a table
if (!editor.isActive("table")) return false;
// Get the current selection
const { selection } = editor.state;
// Find the table node and its position
const tableNode = findParentNodeOfType(selection, "table");
if (!tableNode) return false;
const tablePos = tableNode.pos;
const table = tableNode.node;
// Determine if the selection is in the last row of the table
const rowCount = table.childCount;
const lastRow = table.child(rowCount - 1);
const selectionPath = (selection.$anchor as any).path;
const selectionInLastRow = selectionPath.includes(lastRow);
if (!selectionInLastRow) return false;
// Calculate the position immediately after the table
const nextNodePos = tablePos + table.nodeSize;
// Check for an existing node immediately after the table
const nextNode = editor.state.doc.nodeAt(nextNodePos);
if (nextNode && nextNode.type.name === "paragraph") {
// If the next node is an paragraph, move the cursor there
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
editor.chain().setTextSelection(endOfParagraphPos).run();
} else if (!nextNode) {
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
editor
.chain()
.setTextSelection(nextNodePos + 1)
.run();
} else {
return false;
}
return true;
};

View File

@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item"; import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list"; import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown"; import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { Table } from "src/ui/extensions/table/table"; import { Table } from "src/ui/extensions/table/table";
@ -17,6 +16,11 @@ import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions"; import { Mentions } from "src/ui/mentions";
import { IMentionSuggestion } from "src/types/mention-suggestion"; import { IMentionSuggestion } from "src/types/mention-suggestion";
import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
export const CoreReadOnlyEditorExtensions = (mentionConfig: { export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[]; mentionSuggestions: IMentionSuggestion[];
@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "leading-normal -mb-2", class: "leading-normal -mb-2",
}, },
}, },
blockquote: { code: false,
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false, codeBlock: false,
horizontalRule: { horizontalRule: false,
HTMLAttributes: { class: "mt-4 mb-4" }, blockquote: false,
}, dropcursor: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false, gapcursor: false,
}), }),
Gapcursor, CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
}),
CustomLinkExtension.configure({ CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"], protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url), validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: { HTMLAttributes: {
class: class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
}, },
}), }),
CustomTypographyExtension,
ReadOnlyImageExtension.configure({ ReadOnlyImageExtension.configure({
HTMLAttributes: { HTMLAttributes: {
class: "rounded-lg border border-custom-border-300", class: "rounded-lg border border-custom-border-300",
@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
}, },
nested: true, nested: true,
}), }),
CustomCodeBlockExtension,
CustomCodeInlineExtension,
Markdown.configure({ Markdown.configure({
html: true, html: true,
transformCopiedText: true, transformCopiedText: true,

View File

@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<h2 className="font-medium">Table of Contents</h2> <h2 className="font-medium">Outline</h2>
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
{markings.length !== 0 ? ( {markings.length !== 0 ? (
markings.map((marking) => markings.map((marking) =>

View File

@ -29,11 +29,13 @@ type IPageRenderer = {
editorContentCustomClassNames?: string; editorContentCustomClassNames?: string;
hideDragHandle?: () => void; hideDragHandle?: () => void;
readonly: boolean; readonly: boolean;
tabIndex?: number;
}; };
export const PageRenderer = (props: IPageRenderer) => { export const PageRenderer = (props: IPageRenderer) => {
const { const {
documentDetails, documentDetails,
tabIndex,
editor, editor,
editorClassNames, editorClassNames,
editorContentCustomClassNames, editorContentCustomClassNames,
@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => {
); );
return ( return (
<div className="w-full pb-64 md:pl-7 pl-3 pt-5 page-renderer"> <div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? ( {!readonly ? (
<input <input
onChange={(e) => handlePageTitleChange(e.target.value)} onChange={(e) => handlePageTitleChange(e.target.value)}
@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => {
)} )}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}> <div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}> <EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} /> <EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</EditorContainer> </EditorContainer>
</div> </div>
{isOpen && linkViewProps && coordinates && ( {isOpen && linkViewProps && coordinates && (

View File

@ -47,6 +47,8 @@ interface IDocumentEditor {
duplicationConfig?: IDuplicationConfig; duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig; pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig; pageArchiveConfig?: IPageArchiveConfig;
tabIndex?: number;
} }
interface DocumentEditorProps extends IDocumentEditor { interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>; forwardedRef?: React.Ref<EditorHandle>;
@ -79,6 +81,7 @@ const DocumentEditor = ({
cancelUploadImage, cancelUploadImage,
onActionCompleteHandler, onActionCompleteHandler,
rerenderOnPropsChange, rerenderOnPropsChange,
tabIndex,
}: IDocumentEditor) => { }: IDocumentEditor) => {
const { markings, updateMarkings } = useEditorMarkings(); const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true); const [sidePeekVisible, setSidePeekVisible] = useState(true);
@ -160,6 +163,7 @@ const DocumentEditor = ({
</div> </div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer"> <div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer <PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler} onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave} hideDragHandle={hideDragHandleOnMouseLeave}
readonly={false} readonly={false}

View File

@ -28,6 +28,7 @@ interface IDocumentReadOnlyEditor {
message: string; message: string;
type: "success" | "error" | "warning" | "info"; type: "success" | "error" | "warning" | "info";
}) => void; }) => void;
tabIndex?: number;
} }
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig, pageArchiveConfig,
rerenderOnPropsChange, rerenderOnPropsChange,
onActionCompleteHandler, onActionCompleteHandler,
tabIndex,
}: DocumentReadOnlyEditorProps) => { }: DocumentReadOnlyEditorProps) => {
const router = useRouter(); const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true); const [sidePeekVisible, setSidePeekVisible] = useState(true);
@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
</div> </div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer"> <div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer <PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler} onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()} updatePageTitle={() => Promise.resolve()}
readonly={true} readonly
editor={editor} editor={editor}
editorClassNames={editorClassNames} editorClassNames={editorClassNames}
documentDetails={documentDetails} documentDetails={documentDetails}

View File

@ -42,6 +42,7 @@ interface ILiteTextEditor {
mentionHighlights?: string[]; mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[]; mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode; submitButton?: React.ReactNode;
tabIndex?: number;
} }
interface LiteTextEditorProps extends ILiteTextEditor { interface LiteTextEditorProps extends ILiteTextEditor {
@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
mentionHighlights, mentionHighlights,
mentionSuggestions, mentionSuggestions,
submitButton, submitButton,
tabIndex,
} = props; } = props;
const editor = useEditor({ const editor = useEditor({
@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
return ( return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}> <EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col"> <div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} /> <EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<div className="mt-4 w-full"> <div className="mt-4 w-full">
<FixedMenu <FixedMenu
editor={editor} editor={editor}

View File

@ -8,6 +8,7 @@ interface ICoreReadOnlyEditor {
borderOnFocus?: boolean; borderOnFocus?: boolean;
customClassName?: string; customClassName?: string;
mentionHighlights: string[]; mentionHighlights: string[];
tabIndex?: number;
} }
interface EditorCoreProps extends ICoreReadOnlyEditor { interface EditorCoreProps extends ICoreReadOnlyEditor {
@ -27,6 +28,7 @@ const LiteReadOnlyEditor = ({
value, value,
forwardedRef, forwardedRef,
mentionHighlights, mentionHighlights,
tabIndex,
}: EditorCoreProps) => { }: EditorCoreProps) => {
const editor = useReadOnlyEditor({ const editor = useReadOnlyEditor({
value, value,
@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({
return ( return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}> <EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col"> <div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} /> <EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div> </div>
</EditorContainer> </EditorContainer>
); );

View File

@ -36,6 +36,7 @@ export type IRichTextEditor = {
debouncedUpdatesEnabled?: boolean; debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[]; mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[]; mentionSuggestions?: IMentionSuggestion[];
tabIndex?: number;
}; };
export interface RichTextEditorProps extends IRichTextEditor { export interface RichTextEditorProps extends IRichTextEditor {
@ -68,6 +69,7 @@ const RichTextEditor = ({
mentionHighlights, mentionHighlights,
rerenderOnPropsChange, rerenderOnPropsChange,
mentionSuggestions, mentionSuggestions,
tabIndex,
}: RichTextEditorProps) => { }: RichTextEditorProps) => {
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
@ -100,17 +102,21 @@ const RichTextEditor = ({
customClassName, customClassName,
}); });
React.useEffect(() => { // React.useEffect(() => {
if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); // if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
}, [editor, initialValue]); // }, [editor, initialValue]);
//
if (!editor) return null; if (!editor) return null;
return ( return (
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}> <EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />} {editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col"> <div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} /> <EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div> </div>
</EditorContainer> </EditorContainer>
); );

View File

@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
<button <button
key={item.name} key={item.name}
type="button" type="button"
onClick={item.command} onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn( className={cn(
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5", "p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
{ {

View File

@ -33,8 +33,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100", "flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen } { "bg-custom-background-100": isOpen }
)} )}
onClick={() => { onClick={(e) => {
setIsOpen(!isOpen); setIsOpen(!isOpen);
e.stopPropagation();
}} }}
> >
<p className="text-base"></p> <p className="text-base"></p>
@ -60,6 +61,9 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
ref={inputRef} ref={inputRef}
type="url" type="url"
placeholder="Paste a link" placeholder="Paste a link"
onClick={(e) => {
e.stopPropagation();
}}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400" className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
defaultValue={editor.getAttributes("link").href || ""} defaultValue={editor.getAttributes("link").href || ""}
/> />
@ -67,9 +71,10 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button <button
type="button" type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800" className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => { onClick={(e) => {
unsetLinkEditor(editor); unsetLinkEditor(editor);
setIsOpen(false); setIsOpen(false);
e.stopPropagation();
}} }}
> >
<Trash className="h-4 w-4" /> <Trash className="h-4 w-4" />
@ -78,7 +83,8 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<button <button
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90" className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
type="button" type="button"
onClick={() => { onClick={(e) => {
e.stopPropagation();
onLinkSubmit(); onLinkSubmit();
}} }}
> >

View File

@ -47,7 +47,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<div className="relative h-full"> <div className="relative h-full">
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5" className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
> >
<span>{activeItem?.name}</span> <span>{activeItem?.name}</span>
@ -60,9 +63,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
<button <button
key={item.name} key={item.name}
type="button" type="button"
onClick={() => { onClick={(e) => {
item.command(); item.command();
setIsOpen(false); setIsOpen(false);
e.stopPropagation();
}} }}
className={cn( className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100", "flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",

View File

@ -9,6 +9,7 @@ interface IRichTextReadOnlyEditor {
borderOnFocus?: boolean; borderOnFocus?: boolean;
customClassName?: string; customClassName?: string;
mentionHighlights?: string[]; mentionHighlights?: string[];
tabIndex?: number;
} }
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {

View File

@ -1,11 +1,7 @@
import type { TIssue, IIssueFilterOptions } from "@plane/types"; import type { TIssue, IIssueFilterOptions } from "@plane/types";
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft"; export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export type TCycleLayout = "list" | "board" | "gantt";
export interface ICycle { export interface ICycle {
backlog_issues: number; backlog_issues: number;
cancelled_issues: number; cancelled_issues: number;
@ -30,6 +26,7 @@ export interface ICycle {
sort_order: number; sort_order: number;
start_date: string | null; start_date: string | null;
started_issues: number; started_issues: number;
sub_issues: number;
total_issues: number; total_issues: number;
unstarted_issues: number; unstarted_issues: number;
updated_at: Date; updated_at: Date;

View File

@ -0,0 +1,19 @@
export type TCycleTabOptions = "active" | "all";
export type TCycleLayoutOptions = "list" | "board" | "gantt";
export type TCycleDisplayFilters = {
active_tab?: TCycleTabOptions;
layout?: TCycleLayoutOptions;
};
export type TCycleFilters = {
end_date?: string[] | null;
start_date?: string[] | null;
status?: string[] | null;
};
export type TCycleStoredFilters = {
display_filters?: TCycleDisplayFilters;
filters?: TCycleFilters;
};

View File

@ -0,0 +1,2 @@
export * from "./cycle_filters";
export * from "./cycle";

View File

@ -1,6 +1,6 @@
export * from "./users"; export * from "./users";
export * from "./workspace"; export * from "./workspace";
export * from "./cycles"; export * from "./cycle";
export * from "./dashboard"; export * from "./dashboard";
export * from "./projects"; export * from "./projects";
export * from "./state"; export * from "./state";

View File

@ -30,6 +30,7 @@ export interface IModule {
name: string; name: string;
project_id: string; project_id: string;
sort_order: number; sort_order: number;
sub_issues: number;
start_date: string | null; start_date: string | null;
started_issues: number; started_issues: number;
status: TModuleStatus; status: TModuleStatus;

View File

@ -23,6 +23,8 @@ export type TProjectLogoProps = {
export interface IProject { export interface IProject {
archive_in: number; archive_in: number;
archived_issues: number;
archived_sub_issues: number;
close_in: number; close_in: number;
created_at: Date; created_at: Date;
created_by: string; created_by: string;
@ -35,6 +37,8 @@ export interface IProject {
default_assignee: IUser | string | null; default_assignee: IUser | string | null;
default_state: string | null; default_state: string | null;
description: string; description: string;
draft_issues: number;
draft_sub_issues: number;
estimate: string | null; estimate: string | null;
id: string; id: string;
identifier: string; identifier: string;
@ -48,7 +52,9 @@ export interface IProject {
network: number; network: number;
project_lead: IUserLite | string | null; project_lead: IUserLite | string | null;
sort_order: number | null; sort_order: number | null;
sub_issues: number;
total_cycles: number; total_cycles: number;
total_issues: number;
total_members: number; total_members: number;
total_modules: number; total_modules: number;
updated_at: Date; updated_at: Date;

View File

@ -1,4 +0,0 @@
module.exports = {
root: true,
extends: ["custom"],
};

View File

@ -14,7 +14,6 @@
"scripts": { "scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react --minify", "build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react", "dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
}, },
"dependencies": { "dependencies": {

View File

@ -4,7 +4,7 @@ import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => ( export const CircleDotFullIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
<svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}> <svg viewBox="0 0 16 16" className={`${className} stroke-1`} fill="none" xmlns="http://www.w3.org/2000/svg" {...rest}>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" stroke-linecap="round" /> <circle cx="8.33333" cy="8.33333" r="5.33333" stroke="currentColor" strokeLinecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" /> <circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg> </svg>
); );

View File

@ -127,7 +127,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200"> <Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm" className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
> >
{distribution?.assignees.length > 0 ? ( {distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => { distribution.assignees.map((assignee, index) => {
@ -187,7 +187,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab.Panel> </Tab.Panel>
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm" className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
> >
{distribution?.labels.length > 0 ? ( {distribution?.labels.length > 0 ? (
distribution.labels.map((label, index) => ( distribution.labels.map((label, index) => (
@ -230,7 +230,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab.Panel> </Tab.Panel>
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm" className="flex w-full flex-col gap-1.5 overflow-y-auto pt-3.5 vertical-scrollbar scrollbar-sm"
> >
{Object.keys(groupedIssues).map((group, index) => ( {Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats <SingleProgressStats

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
export * from "./upcoming-cycles-list";

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import useSWR from "swr"; import useSWR from "swr";
// hooks // hooks
import { useCycle, useIssues, useMember, useProject } from "hooks/store"; import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store";
// ui // ui
import { SingleProgressStats } from "components/core"; import { SingleProgressStats } from "components/core";
import { import {
@ -17,10 +17,11 @@ import {
Avatar, Avatar,
CycleGroupIcon, CycleGroupIcon,
setPromiseToast, setPromiseToast,
getButtonStyling,
} from "@plane/ui"; } from "@plane/ui";
// components // components
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats } from "components/cycles"; import { ActiveCycleProgressStats, UpcomingCyclesList } from "components/cycles";
import { StateDropdown } from "components/dropdowns"; import { StateDropdown } from "components/dropdowns";
import { EmptyState } from "components/empty-state"; import { EmptyState } from "components/empty-state";
// icons // icons
@ -28,6 +29,7 @@ import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-re
// helpers // helpers
import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper"; import { renderFormattedDate, findHowManyDaysLeft, renderFormattedDateWithoutYear } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper"; import { truncateText } from "helpers/string.helper";
import { cn } from "helpers/common.helper";
// types // types
import { ICycle, TCycleGroups } from "@plane/types"; import { ICycle, TCycleGroups } from "@plane/types";
// constants // constants
@ -41,30 +43,34 @@ interface IActiveCycleDetails {
projectId: string; projectId: string;
} }
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => { export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props // props
const { workspaceSlug, projectId } = props; const { workspaceSlug, projectId } = props;
// store hooks
const { const {
issues: { fetchActiveCycleIssues }, issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE); } = useIssues(EIssuesStoreType.CYCLE);
const { const {
fetchActiveCycle,
currentProjectActiveCycleId, currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
getActiveCycleById, getActiveCycleById,
addCycleToFavorites, addCycleToFavorites,
removeCycleFromFavorites, removeCycleFromFavorites,
} = useCycle(); } = useCycle();
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// cycle filters hook
const { updateDisplayFilters } = useCycleFilter();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle details
const { isLoading } = useSWR( const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
); );
// fetch active cycle issues
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
const { data: activeCycleIssues } = useSWR( const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" }) ? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
@ -73,7 +79,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId) ? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null : null
); );
// show loader if active cycle is loading
if (!activeCycle && isLoading) if (!activeCycle && isLoading)
return ( return (
<Loader> <Loader>
@ -81,10 +87,44 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</Loader> </Loader>
); );
if (!activeCycle) return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />; if (!activeCycle) {
// show empty state if no active cycle is present
if (currentProjectUpcomingCycleIds?.length === 0)
return <EmptyState type={EmptyStateType.PROJECT_CYCLE_ACTIVE} size="sm" />;
// show upcoming cycles list, if present
else
return (
<>
<div className="h-52 w-full grid place-items-center mb-6">
<div className="text-center">
<h5 className="text-xl font-medium mb-1">No active cycle</h5>
<p className="text-custom-text-400 text-base">
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button
type="button"
className="text-custom-primary-100 font-medium"
onClick={() =>
updateDisplayFilters(projectId, {
active_tab: "all",
})
}
>
click here
</button>
</p>
</div>
</div>
<UpcomingCyclesList />
</>
);
}
const endDate = new Date(activeCycle.end_date ?? ""); const endDate = new Date(activeCycle.end_date ?? "");
const startDate = new Date(activeCycle.start_date ?? ""); const startDate = new Date(activeCycle.start_date ?? "");
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const groupedIssues: any = { const groupedIssues: any = {
backlog: activeCycle.backlog_issues, backlog: activeCycle.backlog_issues,
@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
cancelled: activeCycle.cancelled_issues, cancelled: activeCycle.cancelled_issues,
}; };
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
color: group.color, color: group.color,
})); }));
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
return ( return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow"> <div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0"> <div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
@ -203,27 +239,15 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200"> <div className="flex items-center gap-2.5 text-custom-text-200">
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? ( <Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
<img
src={cycleOwnerDetails?.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycleOwnerDetails?.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{cycleOwnerDetails?.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span> <span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div> </div>
{activeCycle.assignee_ids.length > 0 && ( {activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200"> <div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup> <AvatarGroup>
{activeCycle.assignee_ids.map((assigne_id) => { {activeCycle.assignee_ids.map((assignee_id) => {
const member = getUserDetails(assigne_id); const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />; return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})} })}
</AvatarGroup> </AvatarGroup>
@ -233,7 +257,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center gap-4 text-custom-text-200"> <div className="flex items-center gap-4 text-custom-text-200">
<div className="flex gap-2"> <div className="flex gap-2">
<LayersIcon className="h-4 w-4 flex-shrink-0" /> <LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
{activeCycle.total_issues} issues {activeCycle.total_issues} issues
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`} href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
className="w-min text-nowrap rounded-md bg-custom-primary px-4 py-2 text-center text-sm font-medium text-white hover:bg-custom-primary/90" className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
> >
View Cycle View cycle
</Link> </Link>
</div> </div>
</div> </div>
@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0"> <div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4"> <div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
<div className="text-custom-primary">High Priority Issues</div> <div className="text-custom-primary">High priority issues</div>
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md"> <div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
{activeCycleIssues ? ( {activeCycleIssues ? (
activeCycleIssues.length > 0 ? ( activeCycleIssues.length > 0 ? (
activeCycleIssues.map((issue: any) => ( activeCycleIssues.map((issue) => (
<Link <Link
key={issue.id} key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@ -314,9 +338,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
<div className="flex flex-shrink-0 items-center gap-1.5"> <div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown <StateDropdown
value={issue.state_id ?? undefined} value={issue.state_id}
onChange={() => {}} onChange={() => {}}
projectId={projectId?.toString() ?? ""} projectId={projectId}
disabled disabled
buttonVariant="background-with-text" buttonVariant="background-with-text"
/> />
@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span> <span>
<LayersIcon className="h-5 w-5 flex-shrink-0 text-custom-text-200" /> <LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
</span> </span>
<span> <span>
Pending Issues -{" "} Pending issues-{" "}
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)} {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
</span> </span>
</div> </div>

View File

@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panels> </Tab.Panels>
) : ( ) : (
<div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200"> <div className="mt-4 grid place-items-center text-center text-sm text-custom-text-200">
There are no high priority issues present in this cycle. There are no issues present in this cycle.
</div> </div>
)} )}
</Tab.Group> </Tab.Group>

View File

@ -0,0 +1,135 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react";
import { Star, User2 } from "lucide-react";
// hooks
import { useCycle, useEventTracker, useMember } from "hooks/store";
// components
import { CycleQuickActions } from "components/cycles";
// ui
import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
// constants
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
type Props = {
cycleId: string;
};
export const UpcomingCycleListItem: React.FC<Props> = observer((props) => {
const { cycleId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store hooks
const { captureEvent } = useEventTracker();
const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycle = getCycleById(cycleId);
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
}
);
setPromiseToast(addToFavoritePromise, {
loading: "Adding cycle to favorites...",
success: {
title: "Success!",
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
});
});
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
if (!cycle) return null;
return (
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`}
className="py-5 px-2 flex items-center justify-between gap-2 hover:bg-custom-background-90"
>
<h6 className="font-medium text-base">{cycle.name}</h6>
<div className="flex items-center gap-4">
{cycle.start_date && cycle.end_date && (
<div className="text-xs text-custom-text-300">
{renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)}
</div>
)}
{cycle.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignee_ids?.map((assigneeId) => {
const member = getUserDetails(assigneeId);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
) : (
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
{workspaceSlug && projectId && (
<CycleQuickActions
cycleId={cycleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
</div>
</Link>
);
});

View File

@ -0,0 +1,25 @@
import { observer } from "mobx-react";
// hooks
import { useCycle } from "hooks/store";
// components
import { UpcomingCycleListItem } from "components/cycles";
export const UpcomingCyclesList = observer(() => {
// store hooks
const { currentProjectUpcomingCycleIds } = useCycle();
if (!currentProjectUpcomingCycleIds) return null;
return (
<div>
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
Upcoming cycles
</div>
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
{currentProjectUpcomingCycleIds.map((cycleId) => (
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
))}
</div>
</div>
);
});

View File

@ -0,0 +1,55 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((props) => {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@ -0,0 +1,3 @@
export * from "./date";
export * from "./root";
export * from "./status";

View File

@ -0,0 +1,90 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
// hooks
import { useUser } from "hooks/store";
// components
import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { TCycleFilters } from "@plane/types";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
appliedFilters: TCycleFilters;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const DATE_FILTERS = ["start_date", "end_date"];
export const CycleAppliedFiltersList: React.FC<Props> = observer((props) => {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
// store hooks
const {
membership: { currentProjectRole },
} = useUser();
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER);
return (
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TCycleFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{filterKey === "status" && (
<AppliedStatusFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("status", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
);
});

View File

@ -0,0 +1,43 @@
import { observer } from "mobx-react-lite";
import { X } from "lucide-react";
import { CYCLE_STATUS } from "constants/cycle";
import { cn } from "helpers/common.helper";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters: React.FC<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
return (
<div
key={status}
className={cn(
"flex items-center gap-1 rounded p-1 text-xs",
statusDetails?.bgColor,
statusDetails?.textColor
)}
>
{statusDetails?.title}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -1,22 +1,12 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
// components // components
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; import { Info, Star } from "lucide-react";
import { import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui";
Avatar, import { CycleQuickActions } from "components/cycles";
AvatarGroup,
CustomMenu,
Tooltip,
LayersIcon,
CycleGroupIcon,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui // ui
// icons // icons
// helpers // helpers
@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace"; import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// constants // constants
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
//.types //.types
@ -38,13 +27,10 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store // store
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
@ -56,7 +42,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
if (!cycleDetails) return null; if (!cycleDetails) return null;
const cycleStatus = cycleDetails.status.toLocaleLowerCase(); const cycleStatus = cycleDetails.status.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycleDetails.end_date ?? ""); const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? "");
const isDateValid = cycleDetails.start_date || cycleDetails.end_date; const isDateValid = cycleDetails.start_date || cycleDetails.end_date;
@ -82,20 +67,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue"; : "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -152,20 +123,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page grid layout");
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => { const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -181,22 +138,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
return ( return (
<div> <div>
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -288,30 +229,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-200" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
))} ))}
<CustomMenu ellipsis className="z-10">
{!isCompleted && isEditingAllowed && ( <CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,25 @@
// components
import { CyclesBoardCard } from "components/cycles";
type Props = {
cycleIds: string[];
peekCycle: string | undefined;
projectId: string;
workspaceSlug: string;
};
export const CyclesBoardMap: React.FC<Props> = (props) => {
const { cycleIds, peekCycle, projectId, workspaceSlug } = props;
return (
<div
className={`w-full grid grid-cols-1 gap-6 ${
peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
))}
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./cycles-board-card";
export * from "./cycles-board-map";
export * from "./root";

View File

@ -0,0 +1,60 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { Disclosure } from "@headlessui/react";
import { ChevronRight } from "lucide-react";
// components
import { CyclePeekOverview, CyclesBoardMap } from "components/cycles";
// helpers
import { cn } from "helpers/common.helper";
export interface ICyclesBoard {
completedCycleIds: string[];
cycleIds: string[];
workspaceSlug: string;
projectId: string;
peekCycle: string | undefined;
}
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props;
return (
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div className="h-full w-full flex flex-col p-8 space-y-8 vertical-scrollbar scrollbar-lg">
<CyclesBoardMap
cycleIds={cycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded flex items-center gap-1">
{({ open }) => (
<>
Completed cycles ({completedCycleIds.length})
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": open,
})}
/>
</>
)}
</Disclosure.Button>
<Disclosure.Panel>
<CyclesBoardMap
cycleIds={completedCycleIds}
peekCycle={peekCycle}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</Disclosure.Panel>
</Disclosure>
)}
</div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
);
});

View File

@ -1,47 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { EmptyState } from "components/empty-state";
// constants
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesBoard {
cycleIds: string[];
filter: string;
workspaceSlug: string;
projectId: string;
peekCycle: string | undefined;
}
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
return (
<>
{cycleIds?.length > 0 ? (
<div className="h-full w-full">
<div className="flex h-full w-full justify-between">
<div
className={`grid h-full w-full grid-cols-1 gap-6 overflow-y-auto p-8 ${
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all vertical-scrollbar scrollbar-lg`}
>
{cycleIds.map((cycleId) => (
<CyclesBoardCard key={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
)}
</>
);
});

View File

@ -1,57 +0,0 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
import { EmptyState } from "components/empty-state";
// ui
import { Loader } from "@plane/ui";
// constants
import { EMPTY_STATE_DETAILS } from "constants/empty-state";
export interface ICyclesList {
cycleIds: string[];
filter: string;
workspaceSlug: string;
projectId: string;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId } = props;
return (
<>
{cycleIds ? (
<>
{cycleIds.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{cycleIds.map((cycleId) => (
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<EmptyState type={`project-cycle-${filter}` as keyof typeof EMPTY_STATE_DETAILS} size="sm" />
)}
</>
) : (
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</>
);
});

View File

@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
import { ListFilter, Search, X } from "lucide-react";
// hooks
import { useCycleFilter } from "hooks/store";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { CycleFiltersSelection } from "components/cycles";
import { FiltersDropdown } from "components/issues";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TCycleFilters } from "@plane/types";
// constants
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle";
type Props = {
projectId: string;
};
export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const { projectId } = props;
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const {
currentProjectDisplayFilters,
currentProjectFilters,
searchQuery,
updateDisplayFilters,
updateFilters,
updateSearchQuery,
} = useCycleFilter();
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
const newValues = currentProjectFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId, { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-b border-custom-border-200 px-4 sm:px-5 sm:pb-0">
<Tab.List as="div" className="flex items-center overflow-x-scroll">
{CYCLE_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`border-b-2 p-4 text-sm font-medium outline-none ${
selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent"
}`
}
>
{tab.name}
</Tab>
))}
</Tab.List>
{currentProjectDisplayFilters?.active_tab !== "active" && (
<div className="hidden h-full sm:flex items-center gap-3 self-end">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} />
</FiltersDropdown>
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{CYCLE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
currentProjectDisplayFilters?.layout == layout.key
? "bg-custom-background-100 shadow-custom-shadow-2xs"
: ""
}`}
onClick={() =>
updateDisplayFilters(projectId, {
layout: layout.key,
})
}
>
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
currentProjectDisplayFilters?.layout == layout.key
? "text-custom-text-100"
: "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
</div>
)}
</div>
);
});

View File

@ -1,43 +1,35 @@
import { FC } from "react"; import { FC } from "react";
import Image from "next/image";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// hooks // hooks
import { useCycle, useCycleFilter } from "hooks/store";
// components // components
import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles";
// ui components // ui
import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui";
import { useCycle } from "hooks/store"; // assets
import NameFilterImage from "public/empty-state/cycle/name-filter.svg";
import AllFiltersImage from "public/empty-state/cycle/all-filters.svg";
// types // types
import { TCycleLayout, TCycleView } from "@plane/types"; import { TCycleLayoutOptions } from "@plane/types";
export interface ICyclesView { export interface ICyclesView {
filter: TCycleView; layout: TCycleLayoutOptions;
layout: TCycleLayout;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string | undefined; peekCycle: string | undefined;
} }
export const CyclesView: FC<ICyclesView> = observer((props) => { export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId, peekCycle } = props; const { layout, workspaceSlug, projectId, peekCycle } = props;
// store hooks // store hooks
const { const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle();
currentProjectCompletedCycleIds, const { searchQuery } = useCycleFilter();
currentProjectDraftCycleIds, // derived values
currentProjectUpcomingCycleIds, const filteredCycleIds = getFilteredCycleIds(projectId);
currentProjectCycleIds, const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
loader,
} = useCycle();
const cyclesList = if (loader || !filteredCycleIds)
filter === "completed"
? currentProjectCompletedCycleIds
: filter === "draft"
? currentProjectDraftCycleIds
: filter === "upcoming"
? currentProjectUpcomingCycleIds
: currentProjectCycleIds;
if (loader || !cyclesList)
return ( return (
<> <>
{layout === "list" && <CycleModuleListLayout />} {layout === "list" && <CycleModuleListLayout />}
@ -46,23 +38,45 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
</> </>
); );
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{searchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return ( return (
<> <>
{layout === "list" && ( {layout === "list" && (
<CyclesList cycleIds={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesList
completedCycleIds={filteredCompletedCycleIds ?? []}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
)} )}
{layout === "board" && ( {layout === "board" && (
<CyclesBoard <CyclesBoard
cycleIds={cyclesList} completedCycleIds={filteredCompletedCycleIds ?? []}
filter={filter} cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle} peekCycle={peekCycle}
/> />
)} )}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={filteredCycleIds} workspaceSlug={workspaceSlug} />}
{layout === "gantt" && <CyclesListGanttChartView cycleIds={cyclesList} workspaceSlug={workspaceSlug} />}
</> </>
); );
}); });

View File

@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-500/20"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-500/20">
<AlertTriangle width={16} strokeWidth={2} className="text-red-600" /> <AlertTriangle width={16} strokeWidth={2} className="text-red-600" />
</div> </div>
<div className="text-xl font-medium 2xl:text-2xl">Delete Cycle</div> <div className="text-xl font-medium 2xl:text-2xl">Delete cycle</div>
</div> </div>
<span> <span>
<p className="text-sm text-custom-text-200"> <p className="text-sm text-custom-text-200">
@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
Cancel Cancel
</Button> </Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit}> <Button variant="danger" size="sm" tabIndex={1} onClick={formSubmit} loading={loader}>
{loader ? "Deleting..." : "Delete Cycle"} {loader ? "Deleting" : "Delete"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterEndDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Due date"
/>
)}
<FilterHeader
title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./end-date";
export * from "./root";
export * from "./start-date";
export * from "./status";

View File

@ -0,0 +1,69 @@
import { useState } from "react";
import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react";
// components
import { FilterEndDate, FilterStartDate, FilterStatus } from "components/cycles";
// types
import { TCycleFilters, TCycleGroups } from "@plane/types";
type Props = {
filters: TCycleFilters;
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
};
export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* cycle status */}
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
handleUpdate={(val) => handleFiltersUpdate("status", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* start date */}
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* end date */}
<div className="py-2">
<FilterEndDate
appliedFilters={filters.end_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("end_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@ -0,0 +1,63 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { DateFilterModal } from "components/core";
import { FilterHeader, FilterOption } from "components/issues";
// constants
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterStartDate: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase()));
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Start date"
/>
)}
<FilterHeader
title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple />
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1,49 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// components
import { FilterHeader, FilterOption } from "components/issues";
// types
import { TCycleGroups } from "@plane/types";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
appliedFilters: TCycleGroups[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterStatus: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Status of the cycle${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((status) => (
<FilterOption
key={status.value}
isChecked={appliedFilters?.includes(status.value) ? true : false}
onClick={() => handleUpdate(status.value)}
title={status.title}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@ -0,0 +1 @@
export * from "./filters";

View File

@ -4,8 +4,7 @@ import { useRouter } from "next/router";
// hooks // hooks
import { CycleGanttBlock } from "components/cycles"; import { CycleGanttBlock } from "components/cycles";
import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart";
import { EUserProjectRoles } from "constants/project"; import { useCycle } from "hooks/store";
import { useCycle, useUser } from "hooks/store";
// components // components
// types // types
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
// store hooks // store hooks
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle(); const { getCycleById, updateCycleDetails } = useCycle();
const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => {
@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
return structuredBlocks; return structuredBlocks;
}; };
const isAllowed =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
return ( return (
<div className="h-full w-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
<GanttChartRoot <GanttChartRoot
@ -67,7 +60,7 @@ export const CyclesListGanttChartView: FC<Props> = observer((props) => {
enableBlockLeftResize={false} enableBlockLeftResize={false}
enableBlockRightResize={false} enableBlockRightResize={false}
enableBlockMove={false} enableBlockMove={false}
enableReorder={isAllowed} enableReorder={false}
/> />
</div> </div>
); );

View File

@ -1,17 +1,16 @@
export * from "./cycles-view"; export * from "./active-cycle";
export * from "./active-cycle-details"; export * from "./applied-filters";
export * from "./active-cycle-stats"; export * from "./board/";
export * from "./dropdowns";
export * from "./gantt-chart"; export * from "./gantt-chart";
export * from "./list";
export * from "./cycle-peek-overview";
export * from "./cycles-view-header";
export * from "./cycles-view"; export * from "./cycles-view";
export * from "./delete-modal";
export * from "./form"; export * from "./form";
export * from "./modal"; export * from "./modal";
export * from "./quick-actions";
export * from "./sidebar"; export * from "./sidebar";
export * from "./transfer-issues-modal"; export * from "./transfer-issues-modal";
export * from "./transfer-issues"; export * from "./transfer-issues";
export * from "./cycles-list";
export * from "./cycles-list-item";
export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";

View File

@ -1,26 +1,14 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; import { Check, Info, Star, User2 } from "lucide-react";
import { import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
CustomMenu, import { CycleQuickActions } from "components/cycles";
Tooltip,
CircularProgressIndicator,
CycleGroupIcon,
AvatarGroup,
Avatar,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_STATUS } from "constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker";
import { EUserWorkspaceRoles } from "constants/workspace";
import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// components // components
// ui // ui
@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
// constants // constants
// types // types
import { TCycleGroups } from "@plane/types"; import { TCycleGroups } from "@plane/types";
import { EUserProjectRoles } from "constants/project";
type TCyclesListItem = { type TCyclesListItem = {
cycleId: string; cycleId: string;
@ -42,33 +31,16 @@ type TCyclesListItem = {
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props; const { cycleId, workspaceSlug, projectId } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store hooks // store hooks
const { setTrackElement, captureEvent } = useEventTracker(); const { captureEvent } = useEventTracker();
const { const {
membership: { currentProjectRole }, membership: { currentProjectRole },
} = useUser(); } = useUser();
const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -125,20 +97,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => { const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router; const { query } = router;
e.preventDefault(); e.preventDefault();
@ -161,7 +119,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const endDate = new Date(cycleDetails.end_date ?? ""); const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? "");
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const cycleTotalIssues = const cycleTotalIssues =
cycleDetails.backlog_issues + cycleDetails.backlog_issues +
@ -184,20 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
return ( return (
<> <>
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row"> <div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden"> <div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
@ -246,7 +190,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div> </div>
)} )}
</div> </div>
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end "> <div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end">
<div className="text-xs text-custom-text-300"> <div className="text-xs text-custom-text-300">
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div> </div>
@ -256,8 +200,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<div className="flex w-10 cursor-default items-center justify-center"> <div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids?.length > 0 ? ( {cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assigne_id) => { {cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assigne_id); const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />; return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})} })}
</AvatarGroup> </AvatarGroup>
@ -281,30 +225,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</button> </button>
)} )}
<CustomMenu ellipsis> <CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</> </>
)} )}
</div> </div>

View File

@ -0,0 +1,20 @@
// components
import { CyclesListItem } from "components/cycles";
type Props = {
cycleIds: string[];
projectId: string;
workspaceSlug: string;
};
export const CyclesListMap: React.FC<Props> = (props) => {
const { cycleIds, projectId, workspaceSlug } = props;
return (
<>
{cycleIds.map((cycleId) => (
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</>
);
};

View File

@ -0,0 +1,3 @@
export * from "./cycles-list-item";
export * from "./cycles-list-map";
export * from "./root";

View File

@ -0,0 +1,49 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { Disclosure } from "@headlessui/react";
import { ChevronRight } from "lucide-react";
// components
import { CyclePeekOverview, CyclesListMap } from "components/cycles";
// helpers
import { cn } from "helpers/common.helper";
export interface ICyclesList {
completedCycleIds: string[];
cycleIds: string[];
workspaceSlug: string;
projectId: string;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props;
return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="mt-4 space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
{({ open }) => (
<>
Completed cycles ({completedCycleIds.length})
<ChevronRight
className={cn("h-3 w-3 transition-all", {
"rotate-90": open,
})}
/>
</>
)}
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel>
</Disclosure>
)}
</div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
</div>
</div>
);
});

View File

@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service";
// components // components
// ui // ui
// types // types
import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
// constants // constants
type CycleModalProps = { type CycleModalProps = {
@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { workspaceProjectIds } = useProject(); const { workspaceProjectIds } = useProject();
const { createCycle, updateCycleDetails } = useCycle(); const { createCycle, updateCycleDetails } = useCycle();
const { setValue: setCycleTab } = useLocalStorage<TCycleView>("cycle_tab", "active"); const { setValue: setCycleTab } = useLocalStorage<TCycleTabOptions>("cycle_tab", "active");
const handleCreateCycle = async (payload: Partial<ICycle>) => { const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { LinkIcon, Pencil, Trash2 } from "lucide-react";
// hooks
import { useCycle, useEventTracker, useUser } from "hooks/store";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
// constants
import { EUserProjectRoles } from "constants/project";
type Props = {
cycleId: string;
projectId: string;
workspaceSlug: string;
};
export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { cycleId, projectId, workspaceSlug } = props;
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
// auth
const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const handleEditCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
};
return (
<>
{cycleDetails && (
<div className="fixed">
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
)}
<CustomMenu ellipsis placement="bottom-end">
{!isCompleted && isEditingAllowed && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</>
);
});

View File

@ -224,7 +224,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <div className="relative">
{cycleDetails && workspaceSlug && projectId && ( {cycleDetails && workspaceSlug && projectId && (
<CycleDeleteModal <CycleDeleteModal
cycle={cycleDetails} cycle={cycleDetails}
@ -236,7 +236,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
)} )}
<> <>
<div className="flex w-full items-center justify-between"> <div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5">
<div> <div>
<button <button
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300" className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
@ -505,6 +505,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
</> </>
</> </div>
); );
}); });

View File

@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC<Props> = observer((props) => {
const { currentView } = useGanttChart(); const { currentView } = useGanttChart();
return ( return (
<div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2 z-10"> <div className="relative flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap px-2.5 py-2">
<div className="flex items-center gap-2 text-lg font-medium">{title}</div> <div className="flex items-center gap-2 text-lg font-medium">{title}</div>
<div className="ml-auto"> <div className="ml-auto">
<div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div> <div className="ml-auto text-sm font-medium">{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}</div>

View File

@ -4,7 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
import { ArrowRight, Plus, PanelRight } from "lucide-react"; import { ArrowRight, Plus, PanelRight } from "lucide-react";
import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
import { ProjectAnalyticsModal } from "components/analytics"; import { ProjectAnalyticsModal } from "components/analytics";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
@ -142,6 +142,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
const canUserCreateIssue = const canUserCreateIssue =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = cycleDetails
? issueFilters?.displayFilters?.sub_issue
? cycleDetails.total_issues + cycleDetails?.sub_issues
: cycleDetails.total_issues
: undefined;
return ( return (
<> <>
<ProjectAnalyticsModal <ProjectAnalyticsModal
@ -197,15 +203,29 @@ export const CycleIssuesHeader: React.FC = observer(() => {
label={ label={
<> <>
<ContrastIcon className="h-3 w-3" /> <ContrastIcon className="h-3 w-3" />
<div className=" w-auto max-w-[70px] sm:max-w-[200px] inline-block truncate line-clamp-1 overflow-hidden whitespace-nowrap"> <div className="flex items-center gap-2 w-auto max-w-[70px] sm:max-w-[200px] truncate">
{cycleDetails?.name && cycleDetails.name} <p className="truncate">{cycleDetails?.name && cycleDetails.name}</p>
{issueCount && issueCount > 0 ? (
<Tooltip
tooltipContent={`There are ${issueCount} ${
issueCount > 1 ? "issues" : "issue"
} in this cycle`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
</> </>
} }
className="ml-1.5 flex-shrink-0" className="ml-1.5 flex-shrink-0 truncate"
placement="bottom-start" placement="bottom-start"
> >
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)} {currentProjectCycleIds?.map((cycleId) => (
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -13,7 +13,7 @@ import { CYCLE_VIEW_LAYOUTS } from "constants/cycle";
import { EUserProjectRoles } from "constants/project"; import { EUserProjectRoles } from "constants/project";
import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store";
import useLocalStorage from "hooks/use-local-storage"; import useLocalStorage from "hooks/use-local-storage";
import { TCycleLayout } from "@plane/types"; import { TCycleLayoutOptions } from "@plane/types";
import { ProjectLogo } from "components/project"; import { ProjectLogo } from "components/project";
export const CyclesHeader: FC = observer(() => { export const CyclesHeader: FC = observer(() => {
@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => {
const canUserCreateCycle = const canUserCreateCycle =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const { setValue: setCycleLayout } = useLocalStorage<TCycleLayout>("cycle_layout", "list"); const { setValue: setCycleLayout } = useLocalStorage<TCycleLayoutOptions>("cycle_layout", "list");
const handleCurrentLayout = useCallback( const handleCurrentLayout = useCallback(
(_layout: TCycleLayout) => { (_layout: TCycleLayoutOptions) => {
setCycleLayout(_layout); setCycleLayout(_layout);
}, },
[setCycleLayout] [setCycleLayout]
@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => {
key={layout.key} key={layout.key}
onClick={() => { onClick={() => {
// handleLayoutChange(ISSUE_LAYOUTS[index].key); // handleLayoutChange(ISSUE_LAYOUTS[index].key);
handleCurrentLayout(layout.key as TCycleLayout); handleCurrentLayout(layout.key as TCycleLayoutOptions);
}} }}
className="flex items-center gap-2" className="flex items-center gap-2"
> >

View File

@ -4,7 +4,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
import { ArrowRight, PanelRight, Plus } from "lucide-react"; import { ArrowRight, PanelRight, Plus } from "lucide-react";
import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
import { ProjectAnalyticsModal } from "components/analytics"; import { ProjectAnalyticsModal } from "components/analytics";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
@ -143,6 +143,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
const canUserCreateIssue = const canUserCreateIssue =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = moduleDetails
? issueFilters?.displayFilters?.sub_issue
? moduleDetails.total_issues + moduleDetails.sub_issues
: moduleDetails.total_issues
: undefined;
return ( return (
<> <>
<ProjectAnalyticsModal <ProjectAnalyticsModal
@ -198,15 +204,29 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
label={ label={
<> <>
<DiceIcon className="h-3 w-3" /> <DiceIcon className="h-3 w-3" />
<div className="w-auto max-w-[70px] sm:max-w-[200px] inline-block truncate line-clamp-1 overflow-hidden whitespace-nowrap"> <div className="flex items-center gap-2 w-auto max-w-[70px] sm:max-w-[200px] truncate">
{moduleDetails?.name && moduleDetails.name} <p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
{issueCount && issueCount > 0 ? (
<Tooltip
tooltipContent={`There are ${issueCount} ${
issueCount > 1 ? "issues" : "issue"
} in this module`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
</> </>
} }
className="ml-1.5 flex-shrink-0" className="ml-1.5 flex-shrink-0"
placement="bottom-start" placement="bottom-start"
> >
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)} {projectModuleIds?.map((moduleId) => (
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -5,7 +5,7 @@ import { ArrowLeft } from "lucide-react";
// hooks // hooks
// constants // constants
// ui // ui
import { Breadcrumbs, LayersIcon } from "@plane/ui"; import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
@ -69,6 +69,12 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
}; };
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues
: undefined;
return ( return (
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
@ -82,7 +88,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
<ArrowLeft fontSize={14} strokeWidth={2} /> <ArrowLeft fontSize={14} strokeWidth={2} />
</button> </button>
</div> </div>
<div> <div className="flex items-center gap-2.5">
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
@ -111,6 +117,16 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
} }
/> />
</Breadcrumbs> </Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
// components // components
import { Breadcrumbs, LayersIcon } from "@plane/ui"; import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
@ -73,11 +73,18 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
}, },
[workspaceSlug, projectId, updateFilters] [workspaceSlug, projectId, updateFilters]
); );
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues
: currentProjectDetails.draft_issues
: undefined;
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div className="flex items-center gap-2.5">
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
@ -103,6 +110,16 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
} }
/> />
</Breadcrumbs> </Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's draft`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react";
// hooks // hooks
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
import { ProjectAnalyticsModal } from "components/analytics"; import { ProjectAnalyticsModal } from "components/analytics";
import { BreadcrumbLink } from "components/common"; import { BreadcrumbLink } from "components/common";
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
@ -102,6 +102,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
const canUserCreateIssue = const canUserCreateIssue =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails?.total_issues + currentProjectDetails?.sub_issues
: currentProjectDetails?.total_issues
: undefined;
return ( return (
<> <>
<ProjectAnalyticsModal <ProjectAnalyticsModal
@ -113,7 +119,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100"> <div className="flex items-center gap-2 p-4 border-b border-custom-border-200 bg-custom-sidebar-background-100">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<SidebarHamburgerToggle /> <SidebarHamburgerToggle />
<div> <div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()}> <Breadcrumbs onBack={() => router.back()}>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
@ -145,6 +151,16 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
} }
/> />
</Breadcrumbs> </Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div> </div>
{currentProjectDetails?.is_deployed && deployUrl && ( {currentProjectDetails?.is_deployed && deployUrl && (
<a <a

View File

@ -73,7 +73,6 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
reset, reset,
watch, watch,
getValues, getValues,
setValue,
} = useForm({ defaultValues }); } = useForm({ defaultValues });
const handleClose = () => { const handleClose = () => {

View File

@ -77,8 +77,8 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
isEmptyFilters isEmptyFilters
? undefined ? undefined
: () => { : () => {
setTrackElement("Cycle issue empty state"); setTrackElement("Module issue empty state");
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
} }
} }
secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)} secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)}

View File

@ -55,14 +55,15 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
const filterKey = key as keyof IIssueFilterOptions; const filterKey = key as keyof IIssueFilterOptions;
if (!value) return; if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return ( return (
<div <div
key={filterKey} key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize" className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
> >
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span> <span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{membersFilters.includes(filterKey) && ( {membersFilters.includes(filterKey) && (
<AppliedMembersFilters <AppliedMembersFilters
editable={isEditingAllowed} editable={isEditingAllowed}

View File

@ -68,7 +68,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0) return null;
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 gap-2.5">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters} appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}

View File

@ -77,7 +77,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId) return null; if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId) return null;
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 gap-2.5">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters} appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}

View File

@ -63,7 +63,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0) return null;
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 gap-2.5">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters} appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}

View File

@ -76,7 +76,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
if (!workspaceSlug || !projectId || Object.keys(appliedFilters).length === 0) return null; if (!workspaceSlug || !projectId || Object.keys(appliedFilters).length === 0) return null;
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 gap-2.5">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters} appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}

View File

@ -68,7 +68,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0) return null;
return ( return (
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4 gap-2.5">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters} appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters} handleClearAllFilters={handleClearAllFilters}

View File

@ -1,6 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import sortBy from "lodash/sortBy";
// components // components
import { StateGroupIcon } from "@plane/ui"; import { StateGroupIcon } from "@plane/ui";
import { FilterHeader, FilterOption } from "components/issues"; import { FilterHeader, FilterOption } from "components/issues";

View File

@ -9,6 +9,7 @@ import { Button } from "@plane/ui";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
icon?: React.ReactNode;
title?: string; title?: string;
placement?: Placement; placement?: Placement;
disabled?: boolean; disabled?: boolean;
@ -17,7 +18,7 @@ type Props = {
}; };
export const FiltersDropdown: React.FC<Props> = (props) => { export const FiltersDropdown: React.FC<Props> = (props) => {
const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props; const { children, icon, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null); const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@ -44,6 +45,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement} ref={setReferenceElement}
variant="neutral-primary" variant="neutral-primary"
size="sm" size="sm"
prependIcon={icon}
appendIcon={ appendIcon={
<ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} /> <ChevronUp className={`transition-all ${open ? "" : "rotate-180"}`} size={14} strokeWidth={2} />
} }
@ -64,9 +66,9 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel> <Popover.Panel className="fixed z-10">
<div <div
className="z-10 overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg" className="overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg my-1"
ref={setPopperElement} ref={setPopperElement}
style={styles.popper} style={styles.popper}
{...attributes.popper} {...attributes.popper}

View File

@ -99,6 +99,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg"
closeOnSelect closeOnSelect
ellipsis ellipsis
> >

View File

@ -60,6 +60,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg"
closeOnSelect closeOnSelect
ellipsis ellipsis
> >

View File

@ -110,6 +110,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg"
closeOnSelect closeOnSelect
ellipsis ellipsis
> >

View File

@ -109,6 +109,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg"
closeOnSelect closeOnSelect
ellipsis ellipsis
> >

View File

@ -110,6 +110,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
placement="bottom-start" placement="bottom-start"
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg"
closeOnSelect closeOnSelect
ellipsis ellipsis
> >

View File

@ -467,7 +467,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
}} }}
mentionHighlights={mentionHighlights} mentionHighlights={mentionHighlights}
mentionSuggestions={mentionSuggestions} mentionSuggestions={mentionSuggestions}
// tabIndex={2} tabIndex={getTabIndex("description_html")}
/> />
)} )}
/> />
@ -703,6 +703,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
setSelectedParentIssue(issue); setSelectedParentIssue(issue);
}} }}
projectId={projectId} projectId={projectId}
issueId={data?.id}
/> />
)} )}
/> />

Some files were not shown because too many files have changed in this diff Show More