diff --git a/apiserver/.env.example b/apiserver/.env.example index eba436691..d8554f400 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db" POSTGRES_DB="plane" 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_HOST="plane-redis" @@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit 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 DOCKERIZED=1 # deprecated @@ -48,16 +39,6 @@ USE_MINIO=1 # Nginx Configuration 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 WEB_URL="http://localhost" diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 0c253fc46..5a1da1570 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -22,10 +22,14 @@ export MACHINE_SIGNATURE=$SIGNATURE # Register instance python manage.py register_instance "$MACHINE_SIGNATURE" + # Load the configuration variable python manage.py configure_instance # Create the default 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 - diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/takeoff.local index 8f62370ec..3194009b2 100755 --- a/apiserver/bin/takeoff.local +++ b/apiserver/bin/takeoff.local @@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256 export MACHINE_SIGNATURE=$SIGNATURE # Register instance -python manage.py register_instance $MACHINE_SIGNATURE +python manage.py register_instance "$MACHINE_SIGNATURE" # Load the configuration variable python manage.py configure_instance # Create the default 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 diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 3b2468aee..100b6314a 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer): class ModuleDetailSerializer(ModuleSerializer): link_module = ModuleLinkSerializer(read_only=True, many=True) + sub_issues = serializers.IntegerField(read_only=True) class Meta(ModuleSerializer.Meta): - fields = ModuleSerializer.Meta.fields + ["link_module"] + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] class ModuleFavoriteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 6840fa8f7..a0c2318e3 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer): 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) total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 9dc25474f..42904a8fc 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -106,15 +106,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) ) .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( completed_issues=Count( "issue_cycle__issue__state__group", @@ -232,7 +223,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -327,13 +317,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet): } if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"][ - "completion_chart" - ] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], + data[0]["distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) ) return Response(data, status=status.HTTP_200_OK) @@ -356,7 +346,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -402,7 +391,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -474,7 +462,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -487,10 +474,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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 = ( self.get_queryset() .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( # necessary fields "id", @@ -507,6 +526,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_source", "external_id", "progress_snapshot", + "sub_issues", # meta fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index cd87442d2..ee9718b59 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -3,7 +3,7 @@ import json # Django Imports 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.fields import ArrayField 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( completed_issues=Count( "issue_module__issue__state__group", @@ -183,7 +174,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -225,7 +215,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -237,7 +226,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): return Response(modules, status=status.HTTP_200_OK) 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 = ( Issue.objects.filter( @@ -380,7 +392,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 6deeea144..74d4e3466 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -46,9 +46,11 @@ from plane.db.models import ( Inbox, ProjectDeployBoard, IssueProperty, + Issue, ) from plane.utils.cache import cache_response + class ProjectViewSet(WebhookMixin, BaseViewSet): serializer_class = ProjectListSerializer model = Project @@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ).data 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): try: workspace = Workspace.objects.get(slug=slug) @@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] + # Cache the below api for 24 hours @cache_response(60 * 60 * 24, user=False) def get(self, request): diff --git a/apiserver/plane/db/management/commands/clear_cache.py b/apiserver/plane/db/management/commands/clear_cache.py new file mode 100644 index 000000000..4dfbe6c10 --- /dev/null +++ b/apiserver/plane/db/management/commands/clear_cache.py @@ -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 diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index dba89c4a6..aece1d644 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -1,7 +1,11 @@ -from django.core.cache import cache -# from django.utils.encoding import force_bytes -# import hashlib +# Python imports 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 @@ -22,21 +26,20 @@ def cache_response(timeout=60 * 60, path=None, user=True): def _wrapped_view(instance, request, *args, **kwargs): # Function to generate cache key 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() key = generate_cache_key(custom_path, auth_header) cached_result = cache.get(key) if cached_result is not None: - print("Cache Hit") return Response( cached_result["data"], status=cached_result["status"] ) - - print("Cache Miss") response = view_func(instance, request, *args, **kwargs) - if response.status_code == 200: + if response.status_code == 200 and not settings.DEBUG: cache.set( key, {"data": response.data, "status": response.status_code}, @@ -71,11 +74,12 @@ def invalidate_cache(path=None, url_params=False, user=True): ) 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) cache.delete(key) - print("Invalidating cache") # Execute the view function return view_func(instance, request, *args, **kwargs) diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index a6f380251..cf6ed55fa 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -5,14 +5,8 @@ x-app-env: &app-env - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - 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_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:-""} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} @@ -28,20 +22,6 @@ x-app-env: &app-env - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-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 "} - - 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 - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 6d2cde0ff..9a755d012 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -7,13 +7,8 @@ API_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 -NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN= SENTRY_ENVIRONMENT=production -GOOGLE_CLIENT_ID= -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -DOCKERIZED=1 # deprecated CORS_ALLOWED_ORIGINS=http://localhost #DB SETTINGS @@ -30,19 +25,7 @@ REDIS_HOST=plane-redis REDIS_PORT=6379 REDIS_URL=redis://${REDIS_HOST}:6379/ -# EMAIL SETTINGS -EMAIL_HOST= -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= -EMAIL_PORT=587 -EMAIL_FROM=Team Plane -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 SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 # DATA STORE SETTINGS diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 5c7a8f08f..c943d4c60 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { @@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) { 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 => { while (node !== null && node.nodeName !== "TABLE") { node = node.parentNode; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 5480a51e9..1b2504b58 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -1,5 +1,5 @@ import { Editor } from "@tiptap/react"; -import { ReactNode } from "react"; +import { FC, ReactNode } from "react"; interface EditorContainerProps { editor: Editor | null; @@ -8,17 +8,54 @@ interface EditorContainerProps { hideDragHandle?: () => void; } -export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => ( -
{ - editor?.chain().focus(undefined, { scrollIntoView: false }).run(); - }} - onMouseLeave={() => { - hideDragHandle?.(); - }} - className={`cursor-text ${editorClassNames}`} - > - {children} -
-); +export const EditorContainer: FC = (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 ( +
{ + hideDragHandle?.(); + }} + className={`cursor-text ${editorClassNames}`} + > + {children} +
+ ); +}; diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 9c0938788..7a6ce30f7 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -1,17 +1,28 @@ import { Editor, EditorContent } from "@tiptap/react"; -import { ReactNode } from "react"; +import { FC, ReactNode } from "react"; import { ImageResizer } from "src/ui/extensions/image/image-resize"; interface EditorContentProps { editor: Editor | null; editorContentCustomClassNames: string | undefined; children?: ReactNode; + tabIndex?: number; } -export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => ( -
- - {editor?.isActive("image") && editor?.isEditable && } - {children} -
-); +export const EditorContentWrapper: FC = (props) => { + const { editor, editorContentCustomClassNames = "", tabIndex, children } = props; + + return ( +
{ + editor?.chain().focus(undefined, { scrollIntoView: false }).run(); + }} + > + + {editor?.isActive("image") && editor?.isEditable && } + {children} +
+ ); +}; diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index db8b1c97b..1431b7755 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image"; import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; import { DeleteImage } from "src/types/delete-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 { attrs: { @@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image"; export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) => ImageExt.extend({ + addKeyboardShortcuts() { + return { + ArrowDown: insertLineBelowImageAction, + ArrowUp: insertLineAboveImageAction, + }; + }, addProseMirrorPlugins() { return [ UploadImagesPlugin(cancelUploadImage), diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts new file mode 100644 index 000000000..a18576b46 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts @@ -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; +}; diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts new file mode 100644 index 000000000..e998c728b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts @@ -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; +}; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 7da381e98..1a932d6d5 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; 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 = ( mentionConfig: { @@ -66,7 +66,6 @@ export const CoreEditorExtensions = ( CustomQuoteExtension.configure({ HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, }), - CustomHorizontalRule.configure({ HTMLAttributes: { class: "mt-4 mb-4" }, }), diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts index ef595eee2..5fd06caf6 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -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 { 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 { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; +import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; export interface TableOptions { HTMLAttributes: Record; @@ -231,6 +233,8 @@ export const Table = Node.create({ "Mod-Backspace": deleteTableWhenAllCellsSelected, Delete: deleteTableWhenAllCellsSelected, "Mod-Delete": deleteTableWhenAllCellsSelected, + ArrowDown: insertLineBelowTableAction, + ArrowUp: insertLineAboveTableAction, }; }, diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts new file mode 100644 index 000000000..d61d21c5b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -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; +}; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts new file mode 100644 index 000000000..28b46084a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -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; +}; diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index cf7c4ee18..93e1b3887 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; -import Gapcursor from "@tiptap/extension-gapcursor"; import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; 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 { IMentionSuggestion } from "src/types/mention-suggestion"; 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: { mentionSuggestions: IMentionSuggestion[]; @@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "leading-normal -mb-2", }, }, - blockquote: { - 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", - }, - }, + code: false, codeBlock: false, - horizontalRule: { - HTMLAttributes: { class: "mt-4 mb-4" }, - }, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 2, - }, + horizontalRule: false, + blockquote: false, + dropcursor: 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({ + openOnClick: true, + autolink: true, + linkOnPaste: true, protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url), HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), + CustomTypographyExtension, ReadOnlyImageExtension.configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", @@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { }, nested: true, }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, Markdown.configure({ html: true, transformCopiedText: true, diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx index be70067a2..926d9a53d 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => { return (
-

Table of Contents

+

Outline

{markings.length !== 0 ? ( markings.map((marking) => diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index 06b9e70ff..7c2717e80 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -29,11 +29,13 @@ type IPageRenderer = { editorContentCustomClassNames?: string; hideDragHandle?: () => void; readonly: boolean; + tabIndex?: number; }; export const PageRenderer = (props: IPageRenderer) => { const { documentDetails, + tabIndex, editor, editorClassNames, editorContentCustomClassNames, @@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => { ); return ( -
+
{!readonly ? ( handlePageTitleChange(e.target.value)} @@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => { )}
- +
{isOpen && linkViewProps && coordinates && ( diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index e9f6d884b..eb54a204b 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -47,6 +47,8 @@ interface IDocumentEditor { duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; + + tabIndex?: number; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -79,6 +81,7 @@ const DocumentEditor = ({ cancelUploadImage, onActionCompleteHandler, rerenderOnPropsChange, + tabIndex, }: IDocumentEditor) => { const { markings, updateMarkings } = useEditorMarkings(); const [sidePeekVisible, setSidePeekVisible] = useState(true); @@ -160,6 +163,7 @@ const DocumentEditor = ({
void; + tabIndex?: number; } interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { @@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({ pageArchiveConfig, rerenderOnPropsChange, onActionCompleteHandler, + tabIndex, }: DocumentReadOnlyEditorProps) => { const router = useRouter(); const [sidePeekVisible, setSidePeekVisible] = useState(true); @@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
Promise.resolve()} - readonly={true} + readonly editor={editor} editorClassNames={editorClassNames} documentDetails={documentDetails} diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 57774ab5d..7986e0c6b 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -42,6 +42,7 @@ interface ILiteTextEditor { mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; submitButton?: React.ReactNode; + tabIndex?: number; } interface LiteTextEditorProps extends ILiteTextEditor { @@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { mentionHighlights, mentionSuggestions, submitButton, + tabIndex, } = props; const editor = useEditor({ @@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { return (
- +
{ const editor = useReadOnlyEditor({ value, @@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({ return (
- +
); diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 2aff5d265..366fa471f 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -36,6 +36,7 @@ export type IRichTextEditor = { debouncedUpdatesEnabled?: boolean; mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; + tabIndex?: number; }; export interface RichTextEditorProps extends IRichTextEditor { @@ -68,6 +69,7 @@ const RichTextEditor = ({ mentionHighlights, rerenderOnPropsChange, mentionSuggestions, + tabIndex, }: RichTextEditorProps) => { const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -100,17 +102,21 @@ const RichTextEditor = ({ customClassName, }); - React.useEffect(() => { - if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); - }, [editor, initialValue]); - + // React.useEffect(() => { + // if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); + // }, [editor, initialValue]); + // if (!editor) return null; return ( {editor && }
- +
); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index 2e7dd25b8..f96e7293e 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC = (props: any) => { +

+
+
+ + + ); + } const endDate = new Date(activeCycle.end_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 = { backlog: activeCycle.backlog_issues, @@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC = observer((props cancelled: activeCycle.cancelled_issues, }; - const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC = observer((props color: group.color, })); - const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; - return (
@@ -203,27 +239,15 @@ export const ActiveCycleDetails: React.FC = observer((props
- {cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? ( - {cycleOwnerDetails?.display_name} - ) : ( - - {cycleOwnerDetails?.display_name.charAt(0)} - - )} + {cycleOwnerDetails?.display_name}
{activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {activeCycle.assignee_ids.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -233,7 +257,7 @@ export const ActiveCycleDetails: React.FC = observer((props
- + {activeCycle.total_issues} issues
@@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC = observer((props - View Cycle + View cycle
@@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC = observer((props
-
High Priority Issues
+
High priority issues
{activeCycleIssues ? ( activeCycleIssues.length > 0 ? ( - activeCycleIssues.map((issue: any) => ( + activeCycleIssues.map((issue) => ( = observer((props
{}} - projectId={projectId?.toString() ?? ""} + projectId={projectId} disabled buttonVariant="background-with-text" /> @@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC = observer((props
- + - Pending Issues -{" "} + Pending issues-{" "} {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle/stats.tsx similarity index 98% rename from web/components/cycles/active-cycle-stats.tsx rename to web/components/cycles/active-cycle/stats.tsx index 0cf7449ae..9ccd11077 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle/stats.tsx @@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ) : (
- There are no high priority issues present in this cycle. + There are no issues present in this cycle.
)} diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx new file mode 100644 index 000000000..af2b02726 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -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 = 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) => { + 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) => { + 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 ( + +
{cycle.name}
+
+ {cycle.start_date && cycle.end_date && ( +
+ {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} +
+ )} + {cycle.assignee_ids?.length > 0 ? ( + + {cycle.assignee_ids?.map((assigneeId) => { + const member = getUserDetails(assigneeId); + return ; + })} + + ) : ( + + + + )} + + {cycle.is_favorite ? ( + + ) : ( + + )} + + {workspaceSlug && projectId && ( + + )} +
+ + ); +}); diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx new file mode 100644 index 000000000..60fa9bb30 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx @@ -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 ( +
+
+ Upcoming cycles +
+
+ {currentProjectUpcomingCycleIds.map((cycleId) => ( + + ))} +
+
+ ); +}); diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx new file mode 100644 index 000000000..0298f12d2 --- /dev/null +++ b/web/components/cycles/applied-filters/date.tsx @@ -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 = 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) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/components/cycles/applied-filters/index.ts b/web/components/cycles/applied-filters/index.ts new file mode 100644 index 000000000..cee9ae349 --- /dev/null +++ b/web/components/cycles/applied-filters/index.ts @@ -0,0 +1,3 @@ +export * from "./date"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/cycles/applied-filters/root.tsx b/web/components/cycles/applied-filters/root.tsx new file mode 100644 index 000000000..39d2ae827 --- /dev/null +++ b/web/components/cycles/applied-filters/root.tsx @@ -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 = 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 ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TCycleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "status" && ( + handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}); diff --git a/web/components/cycles/applied-filters/status.tsx b/web/components/cycles/applied-filters/status.tsx new file mode 100644 index 000000000..1eb28db74 --- /dev/null +++ b/web/components/cycles/applied-filters/status.tsx @@ -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 = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = CYCLE_STATUS.find((s) => s.value === status); + return ( +
+ {statusDetails?.title} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx similarity index 71% rename from web/components/cycles/cycles-board-card.tsx rename to web/components/cycles/board/cycles-board-card.tsx index da97f2d9d..ac95f790d 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -1,22 +1,12 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks // components -import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; -import { - Avatar, - AvatarGroup, - CustomMenu, - Tooltip, - LayersIcon, - CycleGroupIcon, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { Info, Star } from "lucide-react"; +import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } from "components/cycles"; // ui // icons // helpers @@ -24,7 +14,6 @@ import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; // constants import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; //.types @@ -38,13 +27,10 @@ export interface ICyclesBoardCard { export const CyclesBoardCard: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -56,7 +42,6 @@ export const CyclesBoardCard: FC = observer((props) => { if (!cycleDetails) return null; const cycleStatus = cycleDetails.status.toLocaleLowerCase(); - const isCompleted = cycleStatus === "completed"; const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); const isDateValid = cycleDetails.start_date || cycleDetails.end_date; @@ -78,24 +63,10 @@ export const CyclesBoardCard: FC = observer((props) => { ? cycleTotalIssues === 0 ? "0 Issue" : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` + ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` + : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : "0 Issue"; - const handleCopyText = (e: MouseEvent) => { - 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) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -152,20 +123,6 @@ export const CyclesBoardCard: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -181,22 +138,6 @@ export const CyclesBoardCard: FC = observer((props) => { return (
- setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> -
@@ -288,30 +229,8 @@ export const CyclesBoardCard: FC = observer((props) => { ))} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + +
diff --git a/web/components/cycles/board/cycles-board-map.tsx b/web/components/cycles/board/cycles-board-map.tsx new file mode 100644 index 000000000..4218c0d1c --- /dev/null +++ b/web/components/cycles/board/cycles-board-map.tsx @@ -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) => { + const { cycleIds, peekCycle, projectId, workspaceSlug } = props; + + return ( +
+ {cycleIds.map((cycleId) => ( + + ))} +
+ ); +}; diff --git a/web/components/cycles/board/index.ts b/web/components/cycles/board/index.ts new file mode 100644 index 000000000..2e6933d99 --- /dev/null +++ b/web/components/cycles/board/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-board-card"; +export * from "./cycles-board-map"; +export * from "./root"; diff --git a/web/components/cycles/board/root.tsx b/web/components/cycles/board/root.tsx new file mode 100644 index 000000000..26154becf --- /dev/null +++ b/web/components/cycles/board/root.tsx @@ -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 = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx deleted file mode 100644 index 278d55071..000000000 --- a/web/components/cycles/cycles-board.tsx +++ /dev/null @@ -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 = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props; - - return ( - <> - {cycleIds?.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ); -}); diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx deleted file mode 100644 index f6ad64f99..000000000 --- a/web/components/cycles/cycles-list.tsx +++ /dev/null @@ -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 = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId } = props; - - return ( - <> - {cycleIds ? ( - <> - {cycleIds.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ) : ( - - - - - - )} - - ); -}); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx new file mode 100644 index 000000000..b0feede0e --- /dev/null +++ b/web/components/cycles/cycles-view-header.tsx @@ -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 = observer((props) => { + const { projectId } = props; + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(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) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + return ( +
+ + {CYCLE_TABS_LIST.map((tab) => ( + + `border-b-2 p-4 text-sm font-medium outline-none ${ + selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" + }` + } + > + {tab.name} + + ))} + + {currentProjectDisplayFilters?.active_tab !== "active" && ( +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } title="Filters" placement="bottom-end"> + + +
+ {CYCLE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
+
+ )} +
+ ); +}); diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 745ca1bd3..447bd048c 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -1,43 +1,35 @@ import { FC } from "react"; +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks +import { useCycle, useCycleFilter } from "hooks/store"; // components import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; -// ui 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 -import { TCycleLayout, TCycleView } from "@plane/types"; +import { TCycleLayoutOptions } from "@plane/types"; export interface ICyclesView { - filter: TCycleView; - layout: TCycleLayout; + layout: TCycleLayoutOptions; workspaceSlug: string; projectId: string; peekCycle: string | undefined; } export const CyclesView: FC = observer((props) => { - const { filter, layout, workspaceSlug, projectId, peekCycle } = props; + const { layout, workspaceSlug, projectId, peekCycle } = props; // store hooks - const { - currentProjectCompletedCycleIds, - currentProjectDraftCycleIds, - currentProjectUpcomingCycleIds, - currentProjectCycleIds, - loader, - } = useCycle(); + const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle(); + const { searchQuery } = useCycleFilter(); + // derived values + const filteredCycleIds = getFilteredCycleIds(projectId); + const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId); - const cyclesList = - filter === "completed" - ? currentProjectCompletedCycleIds - : filter === "draft" - ? currentProjectDraftCycleIds - : filter === "upcoming" - ? currentProjectUpcomingCycleIds - : currentProjectCycleIds; - - if (loader || !cyclesList) + if (loader || !filteredCycleIds) return ( <> {layout === "list" && } @@ -46,23 +38,45 @@ export const CyclesView: FC = observer((props) => { ); + if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) + return ( +
+
+ No matching cycles +
No matching cycles
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all cycles" + : "Remove the search criteria to see all cycles"} +

+
+
+ ); + return ( <> {layout === "list" && ( - + )} - {layout === "board" && ( )} - - {layout === "gantt" && } + {layout === "gantt" && } ); }); diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index fd7b1f356..0d1cc5921 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC = observer((props) => {
-
Delete Cycle
+
Delete cycle

@@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC = observer((props) => { Cancel -

diff --git a/web/components/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx new file mode 100644 index 000000000..10a401500 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/end-date.tsx @@ -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 = 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 && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/index.ts b/web/components/cycles/dropdowns/filters/index.ts new file mode 100644 index 000000000..3d097b6f0 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/index.ts @@ -0,0 +1,4 @@ +export * from "./end-date"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; diff --git a/web/components/cycles/dropdowns/filters/root.tsx b/web/components/cycles/dropdowns/filters/root.tsx new file mode 100644 index 000000000..d97fcad03 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/root.tsx @@ -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 = observer((props) => { + const { filters, handleFiltersUpdate } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* cycle status */} +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* start date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* end date */} +
+ handleFiltersUpdate("end_date", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx new file mode 100644 index 000000000..87def7e29 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -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 = 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 && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/status.tsx b/web/components/cycles/dropdowns/filters/status.tsx new file mode 100644 index 000000000..79e53a5c8 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/status.tsx @@ -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 = 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 ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleUpdate(status.value)} + title={status.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/index.ts b/web/components/cycles/dropdowns/index.ts new file mode 100644 index 000000000..302e3a1a6 --- /dev/null +++ b/web/components/cycles/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./filters"; diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 521273c51..094fbea7b 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; // hooks import { CycleGanttBlock } from "components/cycles"; import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; -import { EUserProjectRoles } from "constants/project"; -import { useCycle, useUser } from "hooks/store"; +import { useCycle } from "hooks/store"; // components // types import { ICycle } from "@plane/types"; @@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); const { getCycleById, updateCycleDetails } = useCycle(); const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { @@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { return structuredBlocks; }; - const isAllowed = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return (
= observer((props) => { enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} - enableReorder={isAllowed} + enableReorder={false} />
); diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index db5e9de9e..e37d266b7 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -1,17 +1,16 @@ -export * from "./cycles-view"; -export * from "./active-cycle-details"; -export * from "./active-cycle-stats"; +export * from "./active-cycle"; +export * from "./applied-filters"; +export * from "./board/"; +export * from "./dropdowns"; export * from "./gantt-chart"; +export * from "./list"; +export * from "./cycle-peek-overview"; +export * from "./cycles-view-header"; export * from "./cycles-view"; +export * from "./delete-modal"; export * from "./form"; export * from "./modal"; +export * from "./quick-actions"; export * from "./sidebar"; export * from "./transfer-issues-modal"; 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"; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx similarity index 71% rename from web/components/cycles/cycles-list-item.tsx rename to web/components/cycles/list/cycles-list-item.tsx index 9bf1866ff..90c6d5d02 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,26 +1,14 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks -import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; -import { - CustomMenu, - Tooltip, - CircularProgressIndicator, - CycleGroupIcon, - AvatarGroup, - Avatar, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { Check, Info, Star, User2 } from "lucide-react"; +import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } from "components/cycles"; import { CYCLE_STATUS } from "constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; -import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; // components // ui @@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; // constants // types import { TCycleGroups } from "@plane/types"; +import { EUserProjectRoles } from "constants/project"; type TCyclesListItem = { cycleId: string; @@ -42,33 +31,16 @@ type TCyclesListItem = { export const CyclesListItem: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store hooks - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getUserDetails } = useMember(); - const handleCopyText = (e: MouseEvent) => { - 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) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -125,20 +97,6 @@ export const CyclesListItem: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -161,7 +119,7 @@ export const CyclesListItem: FC = observer((props) => { const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const cycleTotalIssues = cycleDetails.backlog_issues + @@ -184,20 +142,6 @@ export const CyclesListItem: FC = observer((props) => { return ( <> - setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - />
@@ -246,7 +190,7 @@ export const CyclesListItem: FC = observer((props) => {
)}
-
+
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
@@ -256,8 +200,8 @@ export const CyclesListItem: FC = observer((props) => {
{cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignee_ids?.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -281,30 +225,7 @@ export const CyclesListItem: FC = observer((props) => { )} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + )}
diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx new file mode 100644 index 000000000..c07b204b1 --- /dev/null +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -0,0 +1,20 @@ +// components +import { CyclesListItem } from "components/cycles"; + +type Props = { + cycleIds: string[]; + projectId: string; + workspaceSlug: string; +}; + +export const CyclesListMap: React.FC = (props) => { + const { cycleIds, projectId, workspaceSlug } = props; + + return ( + <> + {cycleIds.map((cycleId) => ( + + ))} + + ); +}; diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts new file mode 100644 index 000000000..46a3557d7 --- /dev/null +++ b/web/components/cycles/list/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-list-item"; +export * from "./cycles-list-map"; +export * from "./root"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx new file mode 100644 index 000000000..27488d238 --- /dev/null +++ b/web/components/cycles/list/root.tsx @@ -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 = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 2d1640ec9..3f57fc204 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service"; // components // ui // types -import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; // constants type CycleModalProps = { @@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const { workspaceProjectIds } = useProject(); const { createCycle, updateCycleDetails } = useCycle(); - const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); + const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx new file mode 100644 index 000000000..f1c930ccb --- /dev/null +++ b/web/components/cycles/quick-actions.tsx @@ -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 = 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) => { + 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) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setUpdateModal(true); + }; + + const handleDeleteCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setDeleteModal(true); + }; + + return ( + <> + {cycleDetails && ( +
+ setUpdateModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> + setDeleteModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> +
+ )} + + {!isCompleted && isEditingAllowed && ( + <> + + + + Edit cycle + + + + + + Delete cycle + + + + )} + + + + Copy cycle link + + + + + ); +}); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index adf986123..bdbe206fc 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -216,15 +216,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` : cycleDetails.total_issues === 0 - ? "0 Issue" - : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; return ( - <> +
{cycleDetails && workspaceSlug && projectId && ( = observer((props) => { )} <> -
+
- +
); }); diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index fe35c9e52..b4dcd6a62 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC = observer((props) => { const { currentView } = useGanttChart(); return ( -
+
{title}
{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}
diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 5ef1ebf2c..6f9c61545 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; // hooks 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 { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -142,6 +142,12 @@ export const CycleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = 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 ( <> { label={ <> -
- {cycleDetails?.name && cycleDetails.name} +
+

{cycleDetails?.name && cycleDetails.name}

+ {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue" + } in this cycle`} + position="bottom" + > + + {issueCount} + + + ) : null}
} - className="ml-1.5 flex-shrink-0" + className="ml-1.5 flex-shrink-0 truncate" placement="bottom-start" > - {currentProjectCycleIds?.map((cycleId) => )} + {currentProjectCycleIds?.map((cycleId) => ( + + ))} } /> diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 22637147f..6f019f3bd 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -13,7 +13,7 @@ import { CYCLE_VIEW_LAYOUTS } from "constants/cycle"; import { EUserProjectRoles } from "constants/project"; import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; -import { TCycleLayout } from "@plane/types"; +import { TCycleLayoutOptions } from "@plane/types"; import { ProjectLogo } from "components/project"; export const CyclesHeader: FC = observer(() => { @@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); const handleCurrentLayout = useCallback( - (_layout: TCycleLayout) => { + (_layout: TCycleLayoutOptions) => { setCycleLayout(_layout); }, [setCycleLayout] @@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => { key={layout.key} onClick={() => { // handleLayoutChange(ISSUE_LAYOUTS[index].key); - handleCurrentLayout(layout.key as TCycleLayout); + handleCurrentLayout(layout.key as TCycleLayoutOptions); }} className="flex items-center gap-2" > diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 10717ecc3..9505d7145 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; // hooks 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 { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -143,6 +143,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = 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 ( <> { label={ <> -
- {moduleDetails?.name && moduleDetails.name} +
+

{moduleDetails?.name && moduleDetails.name}

+ {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue" + } in this module`} + position="bottom" + > + + {issueCount} + + + ) : null}
} className="ml-1.5 flex-shrink-0" placement="bottom-start" > - {projectModuleIds?.map((moduleId) => )} + {projectModuleIds?.map((moduleId) => ( + + ))} } /> diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index db208aa21..ce226b58e 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -5,7 +5,7 @@ import { ArrowLeft } from "lucide-react"; // hooks // constants // ui -import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components import { BreadcrumbLink } from "components/common"; 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); }; + const issueCount = currentProjectDetails + ? issueFilters?.displayFilters?.sub_issue + ? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues + : currentProjectDetails.archived_issues + : undefined; + return (
@@ -82,7 +88,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
-
+
{ } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in project's archived`} + position="bottom" + > + + {issueCount} + + + ) : null}
diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 4f2929621..789c3f60f 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks // components -import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; @@ -73,11 +73,18 @@ export const ProjectDraftIssueHeader: FC = observer(() => { }, [workspaceSlug, projectId, updateFilters] ); + + const issueCount = currentProjectDetails + ? issueFilters?.displayFilters?.sub_issue + ? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues + : currentProjectDetails.draft_issues + : undefined; + return (
-
+
{ } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in project's draft`} + position="bottom" + > + + {issueCount} + + + ) : null}
diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 19eaf4f4f..9739e7832 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks -import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -102,6 +102,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = 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 ( <> {
-
+
router.back()}> { } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in this project`} + position="bottom" + > + + {issueCount} + + + ) : null}
{currentProjectDetails?.is_deployed && deployUrl && ( = observer((props) => { reset, watch, getValues, - setValue, } = useForm({ defaultValues }); const handleClose = () => { diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index 6c0cd0cd6..d8e3516cd 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -77,8 +77,8 @@ export const ModuleEmptyState: React.FC = observer((props) => { isEmptyFilters ? undefined : () => { - setTrackElement("Cycle issue empty state"); - toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); + setTrackElement("Module issue empty state"); + toggleCreateIssueModal(true, EIssuesStoreType.MODULE); } } secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 10ad265f3..94f2b30d1 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -55,14 +55,15 @@ export const AppliedFiltersList: React.FC = observer((props) => { const filterKey = key as keyof IIssueFilterOptions; if (!value) return; + if (Array.isArray(value) && value.length === 0) return; return (
- {replaceUnderscoreIfSnakeCase(filterKey)} -
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} {membersFilters.includes(filterKey) && ( { if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId) return null; return ( -
+
{ if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (!workspaceSlug || !projectId || Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
= (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(null); const [popperElement, setPopperElement] = useState(null); @@ -44,6 +45,7 @@ export const FiltersDropdown: React.FC = (props) => { ref={setReferenceElement} variant="neutral-primary" size="sm" + prependIcon={icon} appendIcon={ } @@ -64,9 +66,9 @@ export const FiltersDropdown: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - +
= observer((props placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index dae88a387..9cf394a1b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -60,6 +60,7 @@ export const ArchivedIssueQuickActions: React.FC = (props) => placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index fe713ed23..38b38926f 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -110,6 +110,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index f24f6869e..00d69cba3 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -109,6 +109,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 24a2433d5..a198b6104 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -110,6 +110,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index dc1f42198..f585fae55 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -467,7 +467,7 @@ export const IssueFormRoot: FC = observer((props) => { }} mentionHighlights={mentionHighlights} mentionSuggestions={mentionSuggestions} - // tabIndex={2} + tabIndex={getTabIndex("description_html")} /> )} /> @@ -703,6 +703,7 @@ export const IssueFormRoot: FC = observer((props) => { setSelectedParentIssue(issue); }} projectId={projectId} + issueId={data?.id} /> )} /> diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index c9f28cf98..55be14a60 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -242,7 +242,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( - <> +
{ @@ -257,7 +257,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { setModuleDeleteModal(false)} data={moduleDetails} /> <> -
+
- +
); }); diff --git a/web/constants/cycle.ts b/web/constants/cycle.ts index 8bb43d898..15c5fef69 100644 --- a/web/constants/cycle.ts +++ b/web/constants/cycle.ts @@ -11,36 +11,24 @@ import { } from "lucide-react"; // types -import { TCycleLayout, TCycleView } from "@plane/types"; +import { TCycleLayoutOptions, TCycleTabOptions } from "@plane/types"; -export const CYCLE_TAB_LIST: { - key: TCycleView; +export const CYCLE_TABS_LIST: { + key: TCycleTabOptions; name: string; }[] = [ - { - key: "all", - name: "All", - }, { key: "active", name: "Active", }, { - key: "upcoming", - name: "Upcoming", - }, - { - key: "completed", - name: "Completed", - }, - { - key: "draft", - name: "Drafts", + key: "all", + name: "All", }, ]; export const CYCLE_VIEW_LAYOUTS: { - key: TCycleLayout; + key: TCycleLayoutOptions; icon: any; title: string; }[] = [ @@ -64,6 +52,7 @@ export const CYCLE_VIEW_LAYOUTS: { export const CYCLE_STATUS: { label: string; value: "current" | "upcoming" | "completed" | "draft"; + title: string; color: string; textColor: string; bgColor: string; @@ -71,6 +60,7 @@ export const CYCLE_STATUS: { { label: "day left", value: "current", + title: "Active", color: "#F59E0B", textColor: "text-amber-500", bgColor: "bg-amber-50", @@ -78,6 +68,7 @@ export const CYCLE_STATUS: { { label: "Yet to start", value: "upcoming", + title: "Yet to start", color: "#3F76FF", textColor: "text-blue-500", bgColor: "bg-indigo-50", @@ -85,6 +76,7 @@ export const CYCLE_STATUS: { { label: "Completed", value: "completed", + title: "Completed", color: "#16A34A", textColor: "text-green-600", bgColor: "bg-green-50", @@ -92,6 +84,7 @@ export const CYCLE_STATUS: { { label: "Draft", value: "draft", + title: "Draft", color: "#525252", textColor: "text-custom-text-300", bgColor: "bg-custom-background-90", diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 3e72ef5a6..495bff29f 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -7,7 +7,7 @@ export interface EmptyStateDetails { description?: string; path?: string; primaryButton?: { - icon?: any; + icon?: React.ReactNode; text: string; comicBox?: { title?: string; @@ -15,7 +15,7 @@ export interface EmptyStateDetails { }; }; secondaryButton?: { - icon?: any; + icon?: React.ReactNode; text: string; comicBox?: { title?: string; @@ -51,9 +51,7 @@ export enum EmptyStateType { PROJECT_CYCLES = "project-cycles", PROJECT_CYCLE_NO_ISSUES = "project-cycle-no-issues", PROJECT_CYCLE_ACTIVE = "project-cycle-active", - PROJECT_CYCLE_UPCOMING = "project-cycle-upcoming", - PROJECT_CYCLE_COMPLETED = "project-cycle-completed", - PROJECT_CYCLE_DRAFT = "project-cycle-draft", + PROJECT_CYCLE_ALL = "project-cycle-all", PROJECT_EMPTY_FILTER = "project-empty-filter", PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter", PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter", @@ -288,28 +286,17 @@ const emptyStateDetails = { }, "project-cycle-active": { key: "project-cycle-active", - title: "No active cycles", + title: "No active cycle", description: "An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.", path: "/empty-state/cycle/active", }, - "project-cycle-upcoming": { - key: "project-cycle-upcoming", - title: "No upcoming cycles", - description: "Upcoming cycles on deck! Just add dates to cycles in draft, and they'll show up right here.", - path: "/empty-state/cycle/upcoming", - }, - "project-cycle-completed": { - key: "project-cycle-completed", - title: "No completed cycles", - description: "Any cycle with a past due date is considered completed. Explore all completed cycles here.", - path: "/empty-state/cycle/completed", - }, - "project-cycle-draft": { - key: "project-cycle-draft", - title: "No draft cycles", - description: "No dates added in cycles? Find them here as drafts.", - path: "/empty-state/cycle/draft", + "project-cycle-all": { + key: "project-cycle-all", + title: "No cycles", + description: + "An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.", + path: "/empty-state/cycle/active", }, // empty filters "project-empty-filter": { diff --git a/web/helpers/cycle.helper.ts b/web/helpers/cycle.helper.ts new file mode 100644 index 000000000..4526b7ab2 --- /dev/null +++ b/web/helpers/cycle.helper.ts @@ -0,0 +1,59 @@ +import sortBy from "lodash/sortBy"; +// helpers +import { satisfiesDateFilter } from "helpers/filter.helper"; +// types +import { ICycle, TCycleFilters } from "@plane/types"; + +/** + * @description orders cycles based on their status + * @param {ICycle[]} cycles + * @returns {ICycle[]} + */ +export const orderCycles = (cycles: ICycle[]): ICycle[] => { + if (cycles.length === 0) return []; + + const STATUS_ORDER: { + [key: string]: number; + } = { + current: 1, + upcoming: 2, + draft: 3, + }; + + let filteredCycles = cycles.filter((c) => c.status.toLowerCase() !== "completed"); + filteredCycles = sortBy(filteredCycles, [ + (c) => STATUS_ORDER[c.status.toLowerCase()], + (c) => (c.status.toLowerCase() === "upcoming" ? c.start_date : c.name.toLowerCase()), + ]); + + return filteredCycles; +}; + +/** + * @description filters cycles based on the filter + * @param {ICycle} cycle + * @param {TCycleFilters} filter + * @returns {boolean} + */ +export const shouldFilterCycle = (cycle: ICycle, filter: TCycleFilters): boolean => { + let fallsInFilters = true; + Object.keys(filter).forEach((key) => { + const filterKey = key as keyof TCycleFilters; + if (filterKey === "status" && filter.status && filter.status.length > 0) + fallsInFilters = fallsInFilters && filter.status.includes(cycle.status.toLowerCase()); + if (filterKey === "start_date" && filter.start_date && filter.start_date.length > 0) { + filter.start_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!cycle.start_date && satisfiesDateFilter(new Date(cycle.start_date), dateFilter); + }); + } + if (filterKey === "end_date" && filter.end_date && filter.end_date.length > 0) { + filter.end_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!cycle.end_date && satisfiesDateFilter(new Date(cycle.end_date), dateFilter); + }); + } + }); + + return fallsInFilters; +}; diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index d31a25b3d..3c34fa9da 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -1,3 +1,4 @@ +import { differenceInCalendarDays } from "date-fns"; // types import { IIssueFilterOptions } from "@plane/types"; @@ -13,3 +14,29 @@ export const calculateTotalFilters = (filters: IIssueFilterOptions): number => ) .reduce((curr, prev) => curr + prev, 0) : 0; + +/** + * @description checks if the date satisfies the filter + * @param {Date} date + * @param {string} filter + * @returns {boolean} + */ +export const satisfiesDateFilter = (date: Date, filter: string): boolean => { + const [value, operator, from] = filter.split(";"); + + if (!from) { + if (operator === "after") return date >= new Date(value); + if (operator === "before") return date <= new Date(value); + } + + if (from === "fromnow") { + if (operator === "after") { + if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; + if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; + if (value === "1_months") return differenceInCalendarDays(date, new Date()) >= 30; + if (value === "2_months") return differenceInCalendarDays(date, new Date()) >= 60; + } + } + + return false; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index ff036a529..3ec5c97bf 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -1,7 +1,8 @@ export * from "./use-application"; -export * from "./use-event-tracker"; export * from "./use-calendar-view"; +export * from "./use-cycle-filter"; export * from "./use-cycle"; +export * from "./use-event-tracker"; export * from "./use-dashboard"; export * from "./use-estimate"; export * from "./use-global-view"; diff --git a/web/hooks/store/use-cycle-filter.ts b/web/hooks/store/use-cycle-filter.ts new file mode 100644 index 000000000..50c37508b --- /dev/null +++ b/web/hooks/store/use-cycle-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { ICycleFilterStore } from "store/cycle_filter.store"; + +export const useCycleFilter = (): ICycleFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useCycleFilter must be used within StoreProvider"); + return context.cycleFilter; +}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index 6eaef6c0f..6cf76cd70 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -67,7 +67,7 @@ const CycleDetailPage: NextPageWithLayout = observer(() => {
{cycleId && !isSidebarCollapsed && (
{ + // states const [createModal, setCreateModal] = useState(false); // store hooks const { setTrackElement } = useEventTracker(); @@ -31,28 +38,26 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; - // local storage - const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); - const { storedValue: cycleLayout, setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + // cycle filters hook + const { clearAllFilters, currentProjectDisplayFilters, currentProjectFilters, updateDisplayFilters, updateFilters } = + useCycleFilter(); // derived values const totalCycles = currentProjectCycleIds?.length ?? 0; const project = projectId ? getProjectById(projectId?.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined; + // selected display filters + const cycleTab = currentProjectDisplayFilters?.active_tab; + const cycleLayout = currentProjectDisplayFilters?.layout; - const handleCurrentLayout = useCallback( - (_layout: TCycleLayout) => { - setCycleLayout(_layout); - }, - [setCycleLayout] - ); + const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; - const handleCurrentView = useCallback( - (_view: TCycleView) => { - setCycleTab(_view); - if (_view === "draft") handleCurrentLayout("list"); - }, - [handleCurrentLayout, setCycleTab] - ); + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }; if (!workspaceSlug || !projectId) return null; @@ -89,101 +94,35 @@ const ProjectCyclesPage: NextPageWithLayout = observer(() => { i.key == cycleTab)} - selectedIndex={CYCLE_TAB_LIST.findIndex((i) => i.key == cycleTab)} - onChange={(i) => handleCurrentView(CYCLE_TAB_LIST[i]?.key ?? "active")} + defaultIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)} + selectedIndex={CYCLE_TABS_LIST.findIndex((i) => i.key == cycleTab)} + onChange={(i) => { + if (!projectId) return; + const tab = CYCLE_TABS_LIST[i]; + if (!tab) return; + updateDisplayFilters(projectId.toString(), { + active_tab: tab.key, + }); + }} > -
- - {CYCLE_TAB_LIST.map((tab) => ( - - `border-b-2 p-4 text-sm font-medium outline-none ${ - selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" - }` - } - > - {tab.name} - - ))} - -
- {cycleTab !== "active" && ( -
- {CYCLE_VIEW_LAYOUTS.map((layout) => { - if (layout.key === "gantt" && cycleTab === "draft") return null; - - return ( - - - - ); - })} -
- )} + + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString())} + handleRemoveFilter={handleRemoveFilter} + />
-
- + )} - - {cycleTab && cycleLayout && ( - - )} - - - + - {cycleTab && cycleLayout && ( - )} - - - - {cycleTab && cycleLayout && workspaceSlug && projectId && ( - - )} - - - - {cycleTab && cycleLayout && workspaceSlug && projectId && ( - {
{moduleId && !isSidebarCollapsed && (
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/cycle/name-filter.svg b/web/public/empty-state/cycle/name-filter.svg new file mode 100644 index 000000000..168611119 --- /dev/null +++ b/web/public/empty-state/cycle/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/cycle.store.ts b/web/store/cycle.store.ts index aea87033e..71cb8f924 100644 --- a/web/store/cycle.store.ts +++ b/web/store/cycle.store.ts @@ -11,9 +11,10 @@ import { IssueService } from "services/issue"; import { ProjectService } from "services/project"; import { RootStore } from "store/root.store"; import { ICycle, CycleDateCheckData } from "@plane/types"; +import { orderCycles, shouldFilterCycle } from "helpers/cycle.helper"; export interface ICycleStore { - //Loaders + // loaders loader: boolean; // observables fetchedMap: Record; @@ -27,6 +28,8 @@ export interface ICycleStore { currentProjectDraftCycleIds: string[] | null; currentProjectActiveCycleId: string | null; // computed actions + getFilteredCycleIds: (projectId: string) => string[] | null; + getFilteredCompletedCycleIds: (projectId: string) => string[] | null; getCycleById: (cycleId: string) => ICycle | null; getCycleNameById: (cycleId: string) => string | undefined; getActiveCycleById: (cycleId: string) => ICycle | null; @@ -183,6 +186,49 @@ export class CycleStore implements ICycleStore { return activeCycle || null; } + /** + * @description returns filtered cycle ids based on display filters and filters + * @param {TCycleDisplayFilters} displayFilters + * @param {TCycleFilters} filters + * @returns {string[] | null} + */ + getFilteredCycleIds = computedFn((projectId: string) => { + const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.cycleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let cycles = Object.values(this.cycleMap ?? {}).filter( + (c) => + c.project_id === projectId && + c.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterCycle(c, filters ?? {}) + ); + cycles = orderCycles(cycles); + const cycleIds = cycles.map((c) => c.id); + return cycleIds; + }); + + /** + * @description returns filtered cycle ids based on display filters and filters + * @param {TCycleDisplayFilters} displayFilters + * @param {TCycleFilters} filters + * @returns {string[] | null} + */ + getFilteredCompletedCycleIds = computedFn((projectId: string) => { + const filters = this.rootStore.cycleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.cycleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let cycles = Object.values(this.cycleMap ?? {}).filter( + (c) => + c.project_id === projectId && + c.status.toLowerCase() === "completed" && + c.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterCycle(c, filters ?? {}) + ); + cycles = sortBy(cycles, [(c) => !c.start_date]); + const cycleIds = cycles.map((c) => c.id); + return cycleIds; + }); + /** * @description returns cycle details by cycle id * @param cycleId diff --git a/web/store/cycle_filter.store.ts b/web/store/cycle_filter.store.ts new file mode 100644 index 000000000..064ea4a4e --- /dev/null +++ b/web/store/cycle_filter.store.ts @@ -0,0 +1,145 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TCycleDisplayFilters, TCycleFilters } from "@plane/types"; + +export interface ICycleFilterStore { + // observables + displayFilters: Record; + filters: Record; + searchQuery: string; + // computed + currentProjectDisplayFilters: TCycleDisplayFilters | undefined; + currentProjectFilters: TCycleFilters | undefined; + // computed functions + getDisplayFiltersByProjectId: (projectId: string) => TCycleDisplayFilters | undefined; + getFiltersByProjectId: (projectId: string) => TCycleFilters | undefined; + // actions + updateDisplayFilters: (projectId: string, displayFilters: TCycleDisplayFilters) => void; + updateFilters: (projectId: string, filters: TCycleFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string) => void; +} + +export class CycleFilterStore implements ICycleFilterStore { + // observables + displayFilters: Record = {}; + filters: Record = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentProjectDisplayFilters: computed, + currentProjectFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current project + autorun(() => { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + this.initProjectCycleFilters(projectId); + }); + } + + /** + * @description get display filters of the current project + */ + get currentProjectDisplayFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.displayFilters[projectId]; + } + + /** + * @description get filters of the current project + */ + get currentProjectFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId]; + } + + /** + * @description get display filters of a project by projectId + * @param {string} projectId + */ + getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]); + + /** + * @description get filters of a project by projectId + * @param {string} projectId + */ + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + + /** + * @description initialize display filters and filters of a project + * @param {string} projectId + */ + initProjectCycleFilters = (projectId: string) => { + const displayFilters = this.getDisplayFiltersByProjectId(projectId); + runInAction(() => { + this.displayFilters[projectId] = { + active_tab: displayFilters?.active_tab || "active", + layout: displayFilters?.layout || "list", + }; + this.filters[projectId] = {}; + }); + }; + + /** + * @description update display filters of a project + * @param {string} projectId + * @param {TCycleDisplayFilters} displayFilters + */ + updateDisplayFilters = (projectId: string, displayFilters: TCycleDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [projectId, key], displayFilters[key as keyof TCycleDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a project + * @param {string} projectId + * @param {TCycleFilters} filters + */ + updateFilters = (projectId: string, filters: TCycleFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [projectId, key], filters[key as keyof TCycleFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a project + * @param {string} projectId + */ + clearAllFilters = (projectId: string) => { + runInAction(() => { + this.filters[projectId] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 298cd532e..0390d7ce2 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -18,6 +18,7 @@ import { IStateStore, StateStore } from "./state.store"; import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; +import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -29,6 +30,7 @@ export class RootStore { projectRoot: IProjectRootStore; memberRoot: IMemberRootStore; cycle: ICycleStore; + cycleFilter: ICycleFilterStore; module: IModuleStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; @@ -50,6 +52,7 @@ export class RootStore { this.memberRoot = new MemberRootStore(this); // independent stores this.cycle = new CycleStore(this); + this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); @@ -69,6 +72,7 @@ export class RootStore { this.memberRoot = new MemberRootStore(this); // independent stores this.cycle = new CycleStore(this); + this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this);