Compare commits

..
108 changed files with 1109 additions and 2299 deletions
-16
View File
@@ -1,16 +0,0 @@
{ pkgs, ... }: {
# Which nixpkgs channel to use.
channel = "stable-23.11"; # or "unstable"
# Use https://search.nixos.org/packages to find packages
packages = [
pkgs.nodejs_20
pkgs.python3
];
services.docker.enable = true;
services.postgres.enable = true;
services.redis.enable = true;
}
@@ -1,29 +0,0 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};
@@ -1,4 +1,3 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
+1 -23
View File
@@ -8,16 +8,8 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
import { AuthBanner } from "../authentication";
// ui
// icons
@@ -61,7 +53,6 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
@@ -100,15 +91,6 @@ export const InstanceSignInForm: FC = (props) => {
[formData.email, formData.password, isSubmitting]
);
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
}
}, [errorCode]);
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
@@ -121,11 +103,7 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
<form
className="space-y-4"
-23
View File
@@ -71,16 +71,6 @@ class ModuleSerializer(BaseSerializer):
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project_id=project_id
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project_id=project_id)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -103,19 +93,6 @@ class ModuleSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
+7 -2
View File
@@ -1,7 +1,7 @@
# Python imports
import json
# Django imports
# Django improts
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.db.models import Q, Value, UUIDField
@@ -184,8 +184,13 @@ class InboxIssueAPIEndpoint(BaseAPIView):
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(
workspace__slug=slug,
pk=project_id,
)
# Inbox view
if inbox is None:
if inbox is None and not project.inbox_view:
return Response(
{
"error": "Inbox is not enabled for this project enable it through the project's api"
@@ -92,7 +92,6 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (
+1 -31
View File
@@ -64,16 +64,6 @@ class ModuleWriteSerializer(BaseSerializer):
members = validated_data.pop("member_ids", None)
project = self.context["project"]
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if Module.objects.filter(
name=module_name, project=project
).exists():
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
module = Module.objects.create(**validated_data, project=project)
if members is not None:
ModuleMember.objects.bulk_create(
@@ -96,19 +86,6 @@ class ModuleWriteSerializer(BaseSerializer):
def update(self, instance, validated_data):
members = validated_data.pop("member_ids", None)
module_name = validated_data.get("name")
if module_name:
# Lookup for the module name in the module table for that project
if (
Module.objects.filter(
name=module_name, project=instance.project
)
.exclude(id=instance.id)
.exists()
):
raise serializers.ValidationError(
{"error": "Module with this name already exists"}
)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
@@ -252,14 +229,7 @@ class ModuleDetailSerializer(ModuleSerializer):
cancelled_estimate_points = serializers.FloatField(read_only=True)
class Meta(ModuleSerializer.Meta):
fields = ModuleSerializer.Meta.fields + [
"link_module",
"sub_issues",
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
]
fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues", "backlog_estimate_points", "unstarted_estimate_points", "started_estimate_points", "cancelled_estimate_points"]
class ModuleUserPropertiesSerializer(BaseSerializer):
+1 -34
View File
@@ -167,40 +167,7 @@ class PageLogSerializer(BaseSerializer):
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
class PageVersionDetailSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
fields = "__all__"
read_only_fields = [
"workspace",
"page",
+3 -22
View File
@@ -16,39 +16,26 @@ from .base import BaseSerializer
class UserSerializer(BaseSerializer):
class Meta:
model = User
# Exclude password field from the serializer
fields = [
field.name
for field in User._meta.fields
if field.name != "password"
]
# Make all system fields and email read only
fields = "__all__"
read_only_fields = [
"id",
"username",
"mobile_number",
"email",
"token",
"created_at",
"updated_at",
"is_superuser",
"is_staff",
"is_managed",
"last_active",
"last_login_time",
"last_logout_time",
"last_login_ip",
"last_logout_ip",
"last_login_uagent",
"last_location",
"last_login_medium",
"created_location",
"token_updated_at",
"is_bot",
"is_password_autoset",
"is_email_verified",
"is_active",
"token_updated_at",
]
extra_kwargs = {"password": {"write_only": True}}
# If the user has already filled first name or last name then he is onboarded
def get_is_onboarded(self, obj):
@@ -221,15 +208,9 @@ class ProfileSerializer(BaseSerializer):
class Meta:
model = Profile
fields = "__all__"
read_only_fields = [
"user",
]
class AccountSerializer(BaseSerializer):
class Meta:
model = Account
fields = "__all__"
read_only_fields = [
"user",
]
-12
View File
@@ -6,8 +6,6 @@ from plane.app.views import (
CycleIssueViewSet,
CycleDateCheckEndpoint,
CycleFavoriteViewSet,
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
@@ -108,14 +106,4 @@ urlpatterns = [
CycleArchiveUnarchiveEndpoint.as_view(),
name="cycle-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/progress/",
CycleProgressEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/analytics/",
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
]
+1 -8
View File
@@ -20,7 +20,6 @@ from plane.app.views import (
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
IssuePaginatedViewSet,
)
urlpatterns = [
@@ -39,12 +38,6 @@ urlpatterns = [
),
name="project-issue",
),
# updated v1 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(
@@ -310,5 +303,5 @@ urlpatterns = [
}
),
name="project-issue-draft",
),
)
]
-3
View File
@@ -98,8 +98,6 @@ from .cycle.base import (
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
@@ -114,7 +112,6 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
IssuePaginatedViewSet,
)
from .issue.activity import (
File diff suppressed because it is too large Load Diff
+4 -139
View File
@@ -57,10 +57,10 @@ from plane.utils.paginator import (
from .. import BaseAPIView, BaseViewSet
from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
class IssueListEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def get(self, request, slug, project_id):
issue_ids = request.GET.get("issues", False)
@@ -599,6 +599,7 @@ class IssueViewSet(BaseViewSet):
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST, ROLE.VIEWER])
def patch(self, request, slug, project_id):
issue_property = IssueUserProperty.objects.get(
@@ -629,8 +630,10 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class BulkDeleteIssuesEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN])
def delete(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
@@ -651,141 +654,3 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
{"message": f"{total_issues} issues were deleted"},
status=status.HTTP_200_OK,
)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
return (
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
# converting the datetime fields in paginated data
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
"link_count",
"attachment_count",
"sub_issues_count",
]
if is_description_required:
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
paginated_data = paginate(
base_queryset=base_queryset,
queryset=queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
+7 -9
View File
@@ -5,18 +5,16 @@ from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
class PageVersionEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -27,7 +25,7 @@ class PageVersionEndpoint(BaseAPIView):
pk=pk,
)
# Serialize the page version
serializer = PageVersionDetailSerializer(page_version)
serializer = PageVersionSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
+2 -3
View File
@@ -1,11 +1,11 @@
# Python imports
from uuid import uuid4
# Django import
from django.conf import settings
from django.core.exceptions import ValidationError
# Django import
from django.db import models
from django.core.validators import FileExtensionValidator
# Module import
from .base import BaseModel
@@ -32,7 +32,6 @@ class FileAsset(BaseModel):
asset = models.FileField(
upload_to=get_upload_path,
validators=[
FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]),
file_size,
],
)
+10
View File
@@ -60,6 +60,7 @@ from plane.db.models import (
IssueVote,
ProjectPublicMember,
IssueAttachment,
Project,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
@@ -77,6 +78,15 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
project = Project.objects.get(pk=deploy_board.project_id)
if project.archived_at:
return Response(
{"error": "Project is archived"},
status=status.HTTP_404_NOT_FOUND,
)
if not deploy_board:
return Response(
{"error": "Project is not published"},
-78
View File
@@ -1,78 +0,0 @@
# constants
PAGINATOR_MAX_LIMIT = 1000
class PaginateCursor:
def __init__(self, current_page_size: int, current_page: int, offset: int):
self.current_page_size = current_page_size
self.current_page = current_page
self.offset = offset
def __str__(self):
return f"{self.current_page_size}:{self.current_page}:{self.offset}"
@classmethod
def from_string(self, value):
"""Return the cursor value from string format"""
try:
bits = value.split(":")
if len(bits) != 3:
raise ValueError(
"Cursor must be in the format 'value:offset:is_prev'"
)
return self(int(bits[0]), int(bits[1]), int(bits[2]))
except (TypeError, ValueError) as e:
raise ValueError(f"Invalid cursor format: {e}")
def paginate(base_queryset, queryset, cursor, on_result):
# validating for cursor
if cursor is None:
cursor_object = PaginateCursor(PAGINATOR_MAX_LIMIT, 0, 0)
else:
cursor_object = PaginateCursor.from_string(cursor)
# getting the issues count
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
start_index = cursor_object.current_page * page_size
end_index = min(start_index + page_size, total_results)
# Get the paginated data
paginated_data = queryset[start_index:end_index]
# Create the pagination info object
prev_cursor = f"{page_size}:{cursor_object.current_page-1}:0"
cursor = f"{page_size}:{cursor_object.current_page}:0"
next_cursor = None
if end_index < total_results:
next_cursor = f"{page_size}:{cursor_object.current_page+1}:0"
prev_page_results = False
if cursor_object.current_page > 0:
prev_page_results = True
next_page_results = False
if next_cursor:
next_page_results = True
if on_result:
paginated_data = on_result(paginated_data)
# returning the result
paginated_data = {
"prev_cursor": prev_cursor,
"cursor": cursor,
"next_cursor": next_cursor,
"prev_page_results": prev_page_results,
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"results": paginated_data,
}
return paginated_data
+1
View File
@@ -61,3 +61,4 @@ zxcvbn==4.4.28
pytz==2024.1
# jwt
PyJWT==2.8.0
-1
View File
@@ -32,7 +32,6 @@
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
@@ -1,4 +1,3 @@
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
@@ -158,5 +157,4 @@ export const CoreEditorExtensions = ({
},
includeChildren: true,
}),
CharacterCount,
];
+3 -10
View File
@@ -1,5 +1,7 @@
import { EditorState, Selection } from "@tiptap/pm/state";
import { Extensions, generateJSON, getSchema } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
import { clsx, type ClassValue } from "clsx";
import { CoreEditorExtensionsWithoutProps } from "src/core/extensions/core-without-props";
import { twMerge } from "tailwind-merge";
interface EditorClassNames {
@@ -59,12 +61,3 @@ export const isValidHttpUrl = (string: string): boolean => {
return url.protocol === "http:" || url.protocol === "https:";
};
export const getParagraphCount = (editorState: EditorState | undefined) => {
if (!editorState) return 0;
let paragraphCount = 0;
editorState.doc.descendants((node) => {
if (node.type.name === "paragraph" && node.content.size > 0) paragraphCount++;
});
return paragraphCount;
};
+2 -8
View File
@@ -8,7 +8,6 @@ import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { insertContentAtSavedSelection } from "@/helpers/insert-content-at-cursor-position";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// plane editor providers
@@ -127,8 +126,8 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
@@ -250,11 +249,6 @@ export const useEditor = (props: CustomEditorProps) => {
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}),
[editorRef, savedSelection, fileHandler.upload]
);
@@ -4,7 +4,6 @@ import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
import { getParagraphCount } from "@/helpers/common";
import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
@@ -82,11 +81,6 @@ export const useReadOnlyEditor = ({
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
documentInfo: {
characters: editorRef.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef.current?.state),
words: editorRef.current?.storage?.characterCount?.words?.() ?? 0,
},
}));
if (!editor) {
+1 -6
View File
@@ -6,14 +6,9 @@ import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands,
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
getHTML: () => string;
clearEditor: (emitUpdate?: boolean) => void;
clearEditor: () => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
documentInfo: {
characters: number;
paragraphs: number;
words: number;
};
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
-16
View File
@@ -48,19 +48,3 @@ export type TPageFilters = {
};
export type TPageEmbedType = "mention" | "issue";
export type TPageVersion = {
created_at: string;
created_by: string;
deleted_at: string | null;
description_binary?: string | null;
description_html?: string | null;
description_json?: object;
id: string;
last_saved_at: string;
owned_by: string;
page: string;
updated_at: string;
updated_by: string;
workspace: string;
}
+3 -4
View File
@@ -35,7 +35,6 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
tabIndex,
closeOnSelect,
openOnHover = false,
useCaptureForOutsideClick = false,
} = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
@@ -89,10 +88,10 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}
};
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
useOutsideClickDetector(dropdownRef, closeDropdown);
let menuItems = (
<Menu.Items data-prevent-outside-click={!!portalElement} className={cn("fixed z-10", menuItemsClassName)} static>
<Menu.Items className={cn("fixed z-10", menuItemsClassName)} static>
<div
className={cn(
"my-1 overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none min-w-[12rem] whitespace-nowrap",
@@ -221,4 +220,4 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
CustomMenu.MenuItem = MenuItem;
export { CustomMenu };
export { CustomMenu };
-1
View File
@@ -17,7 +17,6 @@ export interface IDropdownProps {
optionsClassName?: string;
placement?: Placement;
tabIndex?: number;
useCaptureForOutsideClick?: boolean;
}
export interface ICustomMenuDropdownProps extends IDropdownProps {
@@ -1,7 +1,7 @@
import React, { useEffect } from "react";
// TODO: move it to helpers package
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void, useCapture = false) => {
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// get all the element with attribute name data-prevent-outside-click
@@ -31,10 +31,10 @@ const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: ()
};
useEffect(() => {
document.addEventListener("mousedown", handleClick, useCapture);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick, useCapture);
document.removeEventListener("mousedown", handleClick);
};
});
};
@@ -0,0 +1,50 @@
"use client";
import React from "react";
import Image from "next/image";
// ui
import { Button } from "@plane/ui";
type Props = {
title: string;
description?: React.ReactNode;
image: any;
primaryButton?: {
icon?: any;
text: string;
onClick: () => void;
};
secondaryButton?: React.ReactNode;
disabled?: boolean;
};
export const EmptyState: React.FC<Props> = ({
title,
description,
image,
primaryButton,
secondaryButton,
disabled = false,
}) => (
<div className={`flex h-full w-full items-center justify-center`}>
<div className="flex w-full flex-col items-center text-center">
<Image src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />
<h6 className="mb-3 mt-6 text-xl font-semibold sm:mt-8">{title}</h6>
{description && <p className="mb-7 px-5 text-custom-text-300 sm:mb-8">{description}</p>}
<div className="flex items-center gap-4">
{primaryButton && (
<Button
variant="primary"
prependIcon={primaryButton.icon}
onClick={primaryButton.onClick}
disabled={disabled}
>
{primaryButton.text}
</Button>
)}
{secondaryButton}
</div>
</div>
</div>
);
@@ -1,6 +1,11 @@
import { observer } from "mobx-react";
// import { useTheme } from "next-themes";
import { useTheme } from "next-themes";
import { TLoader } from "@plane/types";
import { LogoSpinner } from "@/components/common";
import { EmptyState } from "@/components/common/empty-state";
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp"
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp"
interface Props {
children: string | JSX.Element | JSX.Element[];
@@ -13,6 +18,9 @@ interface Props {
}
export const IssueLayoutHOC = observer((props: Props) => {
const { resolvedTheme } = useTheme();
const { getIssueLoader, getGroupIssueCount } = props;
const issueCount = getGroupIssueCount(undefined, undefined, false);
@@ -25,8 +33,15 @@ export const IssueLayoutHOC = observer((props: Props) => {
);
}
if (getGroupIssueCount(undefined, undefined, false) === 0) {
return <div className="flex w-full h-full items-center justify-center">No Issues Found</div>;
if (issueCount === 0) {
return <div className="flex w-full h-full items-center justify-center">
{/* No Issues Found */}
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title="Project does not exist"
description="The project you are looking for has no issues or has been archived."
/>
</div>;
}
return <>{props.children}</>;
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -2,11 +2,11 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EmptyState } from "@/components/common";
import { PageHead } from "@/components/core";
import { CycleDetailsSidebar } from "@/components/cycles";
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
import { CycleLayoutRoot } from "@/components/issues/issue-layouts";
// constants
// import { EIssuesStoreType } from "@/constants/issue";
@@ -24,17 +24,18 @@ const CycleDetailPage = observer(() => {
const router = useAppRouter();
const { workspaceSlug, projectId, cycleId } = useParams();
// store hooks
const { getCycleById, loader } = useCycle();
const { fetchCycleDetails, getCycleById } = useCycle();
const { getProjectById } = useProject();
// const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
// hooks
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
useCyclesDetails({
workspaceSlug: workspaceSlug.toString(),
projectId: projectId.toString(),
cycleId: cycleId.toString(),
});
// fetching cycle details
const { error } = useSWR(
workspaceSlug && projectId && cycleId ? `CYCLE_DETAILS_${cycleId.toString()}` : null,
workspaceSlug && projectId && cycleId
? () => fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString())
: null
);
// derived values
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined;
@@ -51,7 +52,7 @@ const CycleDetailPage = observer(() => {
return (
<>
<PageHead title={pageTitle} />
{!cycle && !loader ? (
{error ? (
<EmptyState
image={emptyCycle}
title="Cycle does not exist"
@@ -70,7 +71,7 @@ const CycleDetailPage = observer(() => {
{cycleId && !isSidebarCollapsed && (
<div
className={cn(
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 top-0 z-[13]"
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm fixed right-0 top-0 z-10"
)}
style={{
boxShadow:
@@ -69,7 +69,7 @@ const ModuleIssuesPage = observer(() => {
{moduleId && !isSidebarCollapsed && (
<div
className={cn(
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 top-0 z-[13]"
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm fixed right-0 top-0 z-10"
)}
style={{
boxShadow:
@@ -64,7 +64,7 @@ const PageDetailsPage = observer(() => {
<>
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="relative h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
<IssuePeekOverview />
</div>
@@ -2,7 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
import { useParams } from "next/navigation";
import { FileText } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
@@ -10,10 +10,8 @@ import { TLogoProps } from "@plane/types";
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
// helpers
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { usePage, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -27,13 +25,11 @@ export interface IPagesHeaderProps {
export const PageDetailsHeader = observer(() => {
// router
const { workspaceSlug, pageId } = useParams();
const searchParams = useSearchParams();
// state
const [isOpen, setIsOpen] = useState(false);
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = usePage(pageId?.toString() ?? "");
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = page;
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
// use platform
const { isMobile, platform } = usePlatformOS();
// derived values
@@ -59,9 +55,6 @@ export const PageDetailsHeader = observer(() => {
}
};
const pageTitle = getPageName(name);
const isVersionHistoryOverlayActive = !!searchParams.get("version");
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
@@ -150,9 +143,9 @@ export const PageDetailsHeader = observer(() => {
}
/>
</div>
<Tooltip tooltipContent={pageTitle} position="bottom" isMobile={isMobile}>
<Tooltip tooltipContent={name ?? "Page"} position="bottom" isMobile={isMobile}>
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">
{pageTitle}
{name ?? "Page"}
</div>
</Tooltip>
</div>
@@ -163,9 +156,8 @@ export const PageDetailsHeader = observer(() => {
</Breadcrumbs>
</div>
</div>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions />
{isContentEditable && !isVersionHistoryOverlayActive && (
{isContentEditable && (
<Button
variant="primary"
size="sm"
@@ -3,12 +3,11 @@
import { AppHeader, ContentWrapper } from "@/components/core";
// local components
import { ProjectViewsHeader } from "./header";
import { ViewMobileHeader } from "./mobile-header";
export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<ProjectViewsHeader />} mobileHeader={<ViewMobileHeader />} />
<AppHeader header={<ProjectViewsHeader />} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
@@ -1,57 +0,0 @@
"use client";
import { observer } from "mobx-react";
// icons
import { ChevronDown, ListFilter } from "lucide-react";
// components
import { FiltersDropdown } from "@/components/issues/issue-layouts";
import { ViewFiltersSelection } from "@/components/views/filters/filter-selection";
import { ViewOrderByDropdown } from "@/components/views/filters/order-by";
// hooks
import { useMember, useProjectView } from "@/hooks/store";
export const ViewMobileHeader = observer(() => {
// store hooks
const { filters, updateFilters } = useProjectView();
const {
project: { projectMemberIds },
} = useMember();
return (
<>
<div className="md:hidden flex justify-evenly border-b border-custom-border-200 py-2 z-[13] bg-custom-background-100">
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
isMobile
/>
</div>
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
menuButton={
<span className="flex items-center text-sm text-custom-text-200">
Filters
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
</span>
}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
</>
);
});
@@ -74,7 +74,7 @@ export const AppSidebar: FC<IAppSidebar> = observer(() => {
})}
/>
<div
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2", {
"vertical-scrollbar px-4": !sidebarCollapsed,
})}
>
+8 -15
View File
@@ -1,4 +1,4 @@
import { Metadata, Viewport } from "next";
import { Metadata } from "next";
import Script from "next/script";
// styles
import "@/styles/globals.css";
@@ -28,15 +28,6 @@ export const metadata: Metadata = {
},
};
export const viewport: Viewport = {
minimumScale: 1,
initialScale: 1,
maximumScale: 1,
userScalable: false,
width: "device-width",
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0");
@@ -60,6 +51,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
<link rel="manifest" href="/manifest.json" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
/>
{/* preloading */}
<link rel="preload" href={`${API_BASE_URL}/api/instances/`} as="fetch" crossOrigin="use-credentials" />
<link rel="preload" href={`${API_BASE_URL}/api/users/me/ `} as="fetch" crossOrigin="use-credentials" />
@@ -72,12 +67,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
crossOrigin="use-credentials"
/>
</head>
<body className={`h-screen w-screen`}>
<body>
<div id="context-menu-portal" />
<AppProvider>
<div className={`app-container h-full w-full flex flex-col overflow-hidden`}>
<div id="context-menu-portal" />
<div className="h-full w-full overflow-hidden bg-custom-background-100">{children}</div>
</div>
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>{children}</div>
</AppProvider>
</body>
{process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN && (
+1 -1
View File
@@ -19,7 +19,7 @@ export default function ProfileSettingsLayout(props: Props) {
<>
<CommandPalette />
<AuthenticationWrapper>
<div className="relative flex h-full w-full overflow-hidden">
<div className="relative flex h-screen w-full overflow-hidden">
<ProfileLayoutSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
<div className="h-full w-full overflow-hidden">{children}</div>
+7 -14
View File
@@ -12,8 +12,6 @@ import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
import { SidebarNavItem } from "@/components/sidebar";
// constants
import { PROFILE_ACTION_LINKS } from "@/constants/profile";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
@@ -119,11 +117,11 @@ export const ProfileLayoutSidebar = observer(() => {
)}
</div>
</Link>
<div className="flex flex-shrink-0 flex-col overflow-x-hidden">
<div className="flex flex-shrink-0 flex-col overflow-x-hidden px-4">
{!sidebarCollapsed && (
<h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">Your account</h6>
<h6 className="rounded px-1.5 text-sm font-semibold text-custom-sidebar-text-400">Your account</h6>
)}
<div className="vertical-scrollbar scrollbar-sm mt-2 px-4 h-full space-y-1 overflow-y-auto">
<div className="vertical-scrollbar scrollbar-sm mt-2 h-full space-y-1 overflow-y-auto">
{PROFILE_ACTION_LINKS.map((link) => {
if (link.key === "change-password" && currentUser?.is_password_autoset) return null;
@@ -152,17 +150,12 @@ export const ProfileLayoutSidebar = observer(() => {
})}
</div>
</div>
<div className="flex flex-col overflow-x-hidden">
<div className="flex flex-col overflow-x-hidden px-4">
{!sidebarCollapsed && (
<h6 className="rounded px-6 text-sm font-semibold text-custom-sidebar-text-400">Workspaces</h6>
<h6 className="rounded px-1.5 text-sm font-semibold text-custom-sidebar-text-400">Workspaces</h6>
)}
{workspacesList && workspacesList.length > 0 && (
<div
className={cn("vertical-scrollbar scrollbar-xs mt-2 px-4 h-full space-y-1.5 overflow-y-auto", {
"scrollbar-sm": !sidebarCollapsed,
"ml-2.5 px-1": sidebarCollapsed,
})}
>
<div className="vertical-scrollbar scrollbar-sm mt-2 h-full space-y-1.5 overflow-y-auto">
{workspacesList.map((workspace) => (
<Link
key={workspace.id}
@@ -200,7 +193,7 @@ export const ProfileLayoutSidebar = observer(() => {
))}
</div>
)}
<div className="mt-1.5 px-4">
<div className="mt-1.5">
{WORKSPACE_ACTION_LINKS.map((link) => (
<Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}>
<Tooltip
@@ -9,6 +9,7 @@ import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption }
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
isIntakeIssue?: boolean;
};
export const ActivityFilterRoot: FC<TActivityFilterRoot> = (props) => {
@@ -15,33 +15,19 @@ type TProPiceFrequency = "month" | "year";
type TProPlanPrice = {
key: string;
currency: string;
price: number;
price: string;
recurring: TProPiceFrequency;
};
// constants
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
const PRO_PLAN_PRICES: TProPlanPrice[] = [
{ key: "monthly", currency: "$", price: 8, recurring: "month" },
{ key: "yearly", currency: "$", price: 6, recurring: "year" },
{ key: "monthly", price: "$7", recurring: "month" },
{ key: "yearly", price: "$5", recurring: "year" },
];
export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
const { basePlan, features, verticalFeatureList = false, extraFeatures } = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TProPiceFrequency>("month");
// derived
const monthlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "month")?.price ?? 0;
const yearlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "year")?.price ?? 0;
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
// env
const PRO_PLAN_MONTHLY_PAYMENT_URL = process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_PAYMENT_URL ?? "https://plane.so/pro";
const PRO_PLAN_YEARLY_PAYMENT_URL = process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_PAYMENT_URL ?? "https://plane.so/pro";
@@ -69,7 +55,7 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
{price.recurring === "year" && ("Yearly" as string)}
{price.recurring === "year" && (
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
-{yearlyDiscount}%
-28%
</span>
)}
</>
@@ -83,8 +69,8 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
<div className="pt-6 pb-4 text-center font-semibold">
<div className="text-2xl">Plane Pro</div>
<div className="text-3xl">
{price.currency}
{price.price}
{price.recurring === "month" && "$7"}
{price.recurring === "year" && "$5"}
</div>
<div className="text-sm text-custom-text-300">a user per month</div>
</div>
+8 -11
View File
@@ -16,20 +16,18 @@ export type TFeatureList = {
export type TProjectFeatures = {
[key: string]: {
title: string;
description: string;
featureList: TFeatureList;
};
};
export const PROJECT_FEATURES_LIST: TProjectFeatures = {
project_features: {
title: "Projects and issues",
description: "Toggle these on or off this project.",
title: "Features",
featureList: {
cycles: {
property: "cycle_view",
title: "Cycles",
description: "Timebox work as you see fit per project and change frequency from one period to the next.",
description: "Time-box issues and boost momentum, similar to sprints in scrum.",
icon: <ContrastIcon className="h-5 w-5 flex-shrink-0 rotate-180 text-custom-text-300" />,
isPro: false,
isEnabled: true,
@@ -37,7 +35,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
modules: {
property: "module_view",
title: "Modules",
description: "Group work into sub-project-like set-ups with their own leads and assignees.",
description: "Group multiple issues together and track the progress.",
icon: <DiceIcon width={20} height={20} className="flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
@@ -45,7 +43,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
views: {
property: "issue_views_view",
title: "Views",
description: "Save sorts, filters, and display options for later or share them.",
description: "Apply filters to issues and save them to analyse and investigate work.",
icon: <Layers className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
@@ -53,7 +51,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
pages: {
property: "page_view",
title: "Pages",
description: "Write anything like you write anything.",
description: "Document ideas, feature requirements, discussions within your project.",
icon: <FileText className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
@@ -61,7 +59,7 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
inbox: {
property: "inbox_view",
title: "Intake",
description: "Consider and discuss issues before you add them to your project.",
description: "Capture external inputs, move valid issues to workflow.",
icon: <Intake className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: false,
isEnabled: true,
@@ -69,13 +67,12 @@ export const PROJECT_FEATURES_LIST: TProjectFeatures = {
},
},
project_others: {
title: "Work management",
description: "Available only on some plans as indicated by the label next to the feature below.",
title: "Others",
featureList: {
is_time_tracking_enabled: {
property: "is_time_tracking_enabled",
title: "Time Tracking",
description: "Log time, see timesheets, and download full CSVs for your entire workspace.",
description: "Keep the work logs of the users in track ",
icon: <Timer className="h-5 w-5 flex-shrink-0 text-custom-text-300" />,
isPro: true,
isEnabled: false,
@@ -19,7 +19,7 @@ export const ProjectAnalyticsModalHeader: React.FC<Props> = observer((props) =>
<div className="flex items-center gap-2">
<button
type="button"
className="hidden md:grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
className="grid place-items-center p-1 text-custom-text-200 hover:text-custom-text-100"
onClick={() => setFullScreen((prevData) => !prevData)}
>
{fullScreen ? <Shrink size={14} strokeWidth={2} /> : <Expand size={14} strokeWidth={2} />}
@@ -10,7 +10,6 @@ type Props = {
as?: keyof JSX.IntrinsicElements;
classNames?: string;
placeholderChildren?: ReactNode;
defaultValue?: boolean;
};
const RenderIfVisible: React.FC<Props> = (props) => {
@@ -21,11 +20,10 @@ const RenderIfVisible: React.FC<Props> = (props) => {
horizontalOffset = 0,
as = "div",
children,
defaultValue = false,
classNames = "",
placeholderChildren = null, //placeholder children
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(defaultValue);
const [shouldVisible, setShouldVisible] = useState<boolean>();
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
@@ -1,8 +1,8 @@
"use client";
import { FC, Fragment, useCallback, useRef, useState } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react";
import useSWR from "swr";
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
@@ -16,6 +16,7 @@ import { StateDropdown } from "@/components/dropdowns";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
// helper
import { cn } from "@/helpers/common.helper";
@@ -26,7 +27,6 @@ import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web components
import { IssueIdentifier } from "@/plane-web/components/issues";
import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
export type ActiveCycleStatsProps = {
workspaceSlug: string;
@@ -34,11 +34,10 @@ export type ActiveCycleStatsProps = {
cycle: ICycle | null;
cycleId?: string | null;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
cycleIssueDetails: ActiveCycleIssueDetails;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate, cycleIssueDetails } = props;
const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate } = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
@@ -58,12 +57,21 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
}
};
const {
issues: { fetchNextActiveCycleIssues },
issues: { getActiveCycleById, fetchActiveCycleIssues, fetchNextActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
issue: { getIssueById },
setPeekIssue,
} = useIssueDetail();
useSWR(
workspaceSlug && projectId && cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycleId ? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycleId) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const cycleIssueDetails = cycleId ? getActiveCycleById(cycleId) : { nextPageResults: false };
const loadMoreIssues = useCallback(() => {
if (!cycleId) return;
fetchNextActiveCycleIssues(workspaceSlug, projectId, cycleId);
@@ -79,7 +87,6 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
<Loader.Item height="30px" />
</Loader>
);
return cycleId ? (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<Tab.Group
@@ -241,7 +248,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle && !isEmpty(cycle.distribution) ? (
{cycle ? (
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
@@ -299,7 +306,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle && !isEmpty(cycle.distribution) ? (
{cycle ? (
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels?.map((label, index) => (
<SingleProgressStats
@@ -1,8 +1,8 @@
import { FC, Fragment } from "react";
import { FC, Fragment, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { ICycle, TCyclePlotType } from "@plane/types";
import { CustomSelect, Loader } from "@plane/ui";
import { CustomSelect, Loader, Spinner } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EmptyState } from "@/components/empty-state";
@@ -26,15 +26,24 @@ const cycleBurnDownChartOptions = [
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
// hooks
const { getPlotTypeByCycleId, setPlotType } = useCycle();
const { getPlotTypeByCycleId, setPlotType, fetchCycleDetails } = useCycle();
const { currentActiveEstimateId, areEstimateEnabledByProjectId, estimateById } = useProjectEstimates();
// state
const [loader, setLoader] = useState(false);
// derived values
const plotType: TCyclePlotType = (cycle && getPlotTypeByCycleId(cycle.id)) || "burndown";
const onChange = async (value: TCyclePlotType) => {
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
setPlotType(cycle.id, value);
try {
setLoader(true);
await fetchCycleDetails(workspaceSlug, projectId, cycle.id);
setLoader(false);
} catch (error) {
setLoader(false);
setPlotType(cycle.id, plotType);
}
};
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
@@ -46,7 +55,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
cycle && plotType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return cycle && completionChartDistributionData ? (
return cycle ? (
<div className="flex flex-col min-h-[17rem] gap-5 px-3.5 py-4 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="relative flex items-center justify-between gap-4">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
@@ -66,6 +75,7 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observe
</CustomSelect.Option>
))}
</CustomSelect>
{loader && <Spinner className="h-3 w-3" />}
</div>
)}
</div>
@@ -16,33 +16,31 @@ import { useProjectState } from "@/hooks/store";
export type ActiveCycleProgressProps = {
cycle: ICycle | null;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
const { handleFiltersUpdate, cycle } = props;
const { cycle, handleFiltersUpdate } = props;
// store hooks
const { groupedProjectStates } = useProjectState();
// derived values
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
const groupedIssues: any = cycle
? {
completed: cycle?.completed_issues,
started: cycle?.started_issues,
unstarted: cycle?.unstarted_issues,
backlog: cycle?.backlog_issues,
completed: cycle.completed_issues,
started: cycle.started_issues,
unstarted: cycle.unstarted_issues,
backlog: cycle.backlog_issues,
}
: {};
return cycle && cycle.hasOwnProperty("started_issues") ? (
return cycle ? (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
@@ -1,7 +1,15 @@
"use client";
import { useCallback } from "react";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import { Disclosure } from "@headlessui/react";
// types
import { IIssueFilterOptions } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import {
ActiveCycleProductivity,
@@ -13,9 +21,9 @@ import {
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { useCycle } from "@/hooks/store";
import { ActiveCycleIssueDetails } from "@/store/issue/cycle";
import useCyclesDetails from "./use-cycles-details";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
// hooks
import { useCycle, useIssues } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -23,13 +31,56 @@ interface IActiveCycleDetails {
}
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
const { currentProjectActiveCycle, currentProjectActiveCycleId } = useCycle();
// router
const router = useRouter();
// store hooks
const {
handleFiltersUpdate,
cycle: activeCycle,
cycleIssueDetails,
} = useCyclesDetails({ workspaceSlug, projectId, cycleId: currentProjectActiveCycleId });
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectActiveCycle, fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
// fetch active cycle details
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
if (!workspaceSlug || !projectId || !currentProjectActiveCycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
let newValues: string[] = [];
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters, [key]: newValues },
currentProjectActiveCycleId.toString()
);
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${currentProjectActiveCycleId}`);
},
[workspaceSlug, projectId, currentProjectActiveCycleId, issueFilters, updateFilters, router]
);
// show loader if active cycle is loading
if (!currentProjectActiveCycle && isLoading)
return (
<Loader>
<Loader.Item height="250px" />
</Loader>
);
return (
<>
@@ -55,12 +106,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
)}
<div className="bg-custom-background-100 pt-3 pb-6 px-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProgress cycle={activeCycle} handleFiltersUpdate={handleFiltersUpdate} />
<ActiveCycleProductivity
workspaceSlug={workspaceSlug}
projectId={projectId}
@@ -72,7 +118,6 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
cycle={activeCycle}
cycleId={currentProjectActiveCycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails as ActiveCycleIssueDetails}
/>
</div>
</div>
@@ -1,94 +0,0 @@
import { useCallback } from "react";
import isEqual from "lodash/isEqual";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import { IIssueFilterOptions } from "@plane/types";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
import { useCycle, useIssues } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId: string | null;
}
const useCyclesDetails = (props: IActiveCycleDetails) => {
// props
const { workspaceSlug, projectId, cycleId } = props;
// router
const router = useRouter();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle();
// derived values
const cycle = cycleId ? getCycleById(cycleId) : null;
// fetch cycle details
useSWR(
workspaceSlug && projectId && cycle ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS` : null,
workspaceSlug && projectId && cycle ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useSWR(
workspaceSlug && projectId && cycle && !cycle?.distribution ? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION` : null,
workspaceSlug && projectId && cycle && !cycle?.distribution
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues")
: null
);
useSWR(
workspaceSlug && projectId && cycle && !cycle?.estimate_distribution
? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION`
: null,
workspaceSlug && projectId && cycle && !cycle?.estimate_distribution
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points")
: null
);
useSWR(
workspaceSlug && projectId && cycle?.id ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle?.id
? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle?.id)
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false };
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
if (!workspaceSlug || !projectId || !cycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
let newValues: string[] = [];
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters, [key]: newValues },
cycleId.toString()
);
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
},
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router]
);
return {
cycle,
cycleId,
router,
handleFiltersUpdate,
cycleIssueDetails,
};
};
export default useCyclesDetails;
@@ -8,7 +8,7 @@ import { useSearchParams } from "next/navigation";
import { AlertCircle, ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import { ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { CustomSelect, Loader, Spinner } from "@plane/ui";
import { CustomSelect, Spinner } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { CycleProgressStats } from "@/components/cycles";
@@ -231,7 +231,7 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
<span>Current</span>
</div>
</div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
{cycleStartDate && cycleEndDate && completionChartDistributionData && (
<Fragment>
{plotType === "points" ? (
<ProgressChart
@@ -251,10 +251,6 @@ export const CycleAnalyticsProgress: FC<TCycleAnalyticsProgress> = observer((pro
/>
)}
</Fragment>
) : (
<Loader className="w-full h-[160px] mt-4">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
@@ -33,6 +33,7 @@ type Props = {
cycleId: string;
handleClose: () => void;
isArchived?: boolean;
isPeekMode?: boolean;
};
const defaultValues: Partial<ICycle> = {
@@ -45,7 +46,7 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose, isArchived } = props;
const { cycleId, handleClose, isArchived, isPeekMode = false } = props;
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
@@ -261,7 +262,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<>
<div
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 ${isPeekMode ? "pt-5" : "pt-20"}`}
>
<div>
<button
@@ -51,6 +51,7 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
cycleId={peekCycle?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
isPeekMode
/>
</div>
)}
@@ -109,7 +109,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
appendTitleElement={
<button
onClick={openCycleOverview}
className={`z-[1] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
className={`z-[5] flex-shrink-0 ${isMobile ? "flex" : "hidden group-hover:flex"}`}
>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
@@ -85,7 +85,8 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const canMarkAsDeclined = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2);
// can delete only if admin or is creator of the issue
const canDelete =
(!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) || issue?.created_by === currentUser?.id;
(!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) ||
inboxIssue?.created_by === currentUser?.id;
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
// days left for snooze
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
@@ -2,7 +2,6 @@ import { FC, useState } from "react";
import { observer } from "mobx-react";
import { LayoutPanelTop } from "lucide-react";
import { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import {
CycleDropdown,
@@ -95,7 +94,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
<div className="h-7">
<DateDropdown
value={data?.start_date || null}
onChange={(date) => handleData("start_date", date ? renderFormattedPayloadDate(date) : "")}
onChange={(date) => (date ? handleData("start_date", renderFormattedPayloadDate(date)) : null)}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Start date"
@@ -107,7 +106,7 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
<div className="h-7">
<DateDropdown
value={data?.target_date || null}
onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")}
onChange={(date) => (date ? handleData("target_date", renderFormattedPayloadDate(date)) : null)}
buttonVariant="border-with-text"
minDate={minDate ?? undefined}
placeholder="Due date"
@@ -158,43 +157,18 @@ export const InboxIssueProperties: FC<TInboxIssueProperties> = observer((props)
{/* add parent */}
{isVisible && (
<>
{selectedParentIssue ? (
<CustomMenu
customButton={
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`}
</span>
</button>
}
placement="bottom-start"
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueModalOpen(true)}>
Change parent issue
</CustomMenu.MenuItem>
<CustomMenu.MenuItem className="!p-1" onClick={() => handleData("parent_id", "")}>
Remove parent issue
</CustomMenu.MenuItem>
</>
</CustomMenu>
) : (
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span>
</button>
)}
<button
type="button"
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
onClick={() => setParentIssueModalOpen(true)}
>
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">
{selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`}
</span>
</button>
<ParentIssuesListModal
isOpen={parentIssueModalOpen}
handleClose={() => setParentIssueModalOpen(false)}
@@ -1,9 +1,8 @@
import { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { FileRejection, useDropzone } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
// hooks
import {TOAST_TYPE, setToast } from "@plane/ui";
import { MAX_FILE_SIZE } from "@/constants/common";
import { generateFileName } from "@/helpers/attachment.helper";
import { useInstance, useIssueDetail } from "@/hooks/store";
@@ -37,46 +36,24 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
const issueAttachments = getAttachmentsByIssueId(issueId);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles:FileRejection[] ) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
if(rejectedFiles.length===0){
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData)
.catch(()=>{
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
})
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
.finally(() => setIsLoading(false));
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: (totalAttachedFiles>1)?
"Only one file can be uploaded at a time." :
"File must be 5MB or less.",
})
return;
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
},
[handleAttachmentOperations, workspaceSlug]
);
@@ -1,9 +1,8 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { FileRejection, useDropzone } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { Plus } from "lucide-react";
import {TOAST_TYPE, setToast } from "@plane/ui";
// constants
import { MAX_FILE_SIZE } from "@/constants/common";
// helper
@@ -34,54 +33,31 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
// handlers
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles:FileRejection[] ) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
if(rejectedFiles.length===0){
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData)
.catch(()=>{
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "File could not be attached. Try uploading again.",
})
const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), {
type: currentFile.type,
});
const formData = new FormData();
formData.append("asset", uploadedFile);
formData.append(
"attributes",
JSON.stringify({
name: uploadedFile.name,
size: uploadedFile.size,
})
.finally(() => {
setLastWidgetAction("attachments");
setIsLoading(false);
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => {
setLastWidgetAction("attachments");
setIsLoading(false);
});
return;
}
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: (totalAttachedFiles>1)?
"Only one file can be uploaded at a time." :
"File must be 5MB or less.",
})
return;
},
[handleAttachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
maxSize: config?.file_size_limit ?? MAX_FILE_SIZE,
@@ -95,4 +71,4 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
);
});
});
@@ -7,10 +7,9 @@ import { LiteTextEditor } from "@/components/editor/lite-text-editor/lite-text-e
// constants
import { EIssueCommentAccessSpecifier } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
import { isEmptyHtmlString } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useWorkspace } from "@/hooks/store";
import { useWorkspace } from "@/hooks/store";
// editor
import { TActivityOperations } from "../root";
@@ -28,7 +27,6 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const editorRef = useRef<any>(null);
// store hooks
const workspaceStore = useWorkspace();
const { peekIssue } = useIssueDetail();
// derived values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// form info
@@ -60,9 +58,6 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
return (
<div
className={cn("sticky bottom-0 z-10 bg-custom-background-100 sm:static", {
"-bottom-5": !peekIssue,
})}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting)
handleSubmit(onSubmit)(e);
@@ -123,7 +123,7 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
disabled={disabled}
/>
)}
<ActivityFilterRoot selectedFilters={selectedFilters} toggleFilter={toggleFilter} />
<ActivityFilterRoot selectedFilters={selectedFilters} toggleFilter={toggleFilter} isIntakeIssue={isIntakeIssue}/>
</div>
</div>
@@ -78,19 +78,18 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
const handleDeleteIssue = async () => {
try {
const deleteIssue = issue?.archived_at ? removeArchivedIssue : removeIssue;
const redirectionPath = issue?.archived_at
? `/${workspaceSlug}/projects/${projectId}/archives/issues`
: `/${workspaceSlug}/projects/${projectId}/issues`;
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(redirectionPath);
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
if (issue?.archived_at) {
return removeArchivedIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(`/${workspaceSlug}/projects/${projectId}/issues`);
captureIssueEvent({
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
});
});
} else {
return removeIssue(workspaceSlug, projectId, issueId);
}
} catch (error) {
setToast({
title: "Error!",
@@ -122,6 +122,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
const workspaceSlug = routerWorkspaceSlug?.toString();
// hooks
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { isMobile } = usePlatformOS();
const handleIssuePeekOverview = (issue: TIssue) =>
workspaceSlug &&
@@ -216,7 +217,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
{ "bg-custom-background-80 z-[100]": isCurrentBlockDragging }
)}
onClick={() => handleIssuePeekOverview(issue)}
disabled={!!issue?.tempId}
disabled={!!issue?.tempId || isMobile}
>
<RenderIfVisible
classNames="space-y-2 px-3 py-2"
@@ -16,7 +16,7 @@ import { useIssueDetail } from "@/hooks/store";
import { TSelectionHelper } from "@/hooks/use-multiple-select";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// types
import { HIGHLIGHT_CLASS, getIssueBlockId, isIssueNew } from "../utils";
import { HIGHLIGHT_CLASS, getIssueBlockId } from "../utils";
import { TRenderQuickActions } from "./list-view-types";
type Props = {
@@ -36,7 +36,6 @@ type Props = {
canDropOverIssue: boolean;
isParentIssueBeingDragged?: boolean;
isLastChild?: boolean;
shouldRenderByDefault?: boolean;
};
export const IssueBlockRoot: FC<Props> = observer((props) => {
@@ -57,7 +56,6 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
isParentIssueBeingDragged = false,
isLastChild = false,
selectionHelpers,
shouldRenderByDefault,
} = props;
// states
const [isExpanded, setExpanded] = useState<boolean>(false);
@@ -116,7 +114,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
issueBlockRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
});
if (!issueId || !issuesMap[issueId]?.created_at) return null;
if (!issueId) return null;
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
return (
@@ -128,7 +126,6 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
root={containerRef}
classNames={`relative ${isLastChild && !isExpanded ? "" : "border-b border-b-custom-border-200"}`}
verticalOffset={100}
defaultValue={shouldRenderByDefault || isIssueNew(issuesMap[issueId])}
>
<IssueBlock
issueId={issueId}
@@ -168,7 +165,6 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
isDragAllowed={isDragAllowed}
canDropOverIssue={canDropOverIssue}
isParentIssueBeingDragged={isParentIssueBeingDragged || isCurrentBlockDragging}
shouldRenderByDefault={isExpanded}
/>
))}
{isLastChild && <DropIndicator classNames={"absolute z-[2]"} isVisible={instruction === "DRAG_BELOW"} />}
@@ -163,7 +163,6 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -125,7 +125,6 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -183,7 +183,6 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -115,7 +115,6 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -180,7 +180,6 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -174,7 +174,6 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {
@@ -606,16 +606,4 @@ export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => {
}
return true;
};
/**
* This Method returns if the issue is new or not
* @param issue
* @returns
*/
export const isIssueNew = (issue: TIssue) => {
const createdDate = new Date(issue.created_at);
const currentDate = new Date();
const diff = currentDate.getTime() - createdDate.getTime();
return diff < 30000;
};
};
@@ -59,7 +59,7 @@ export const LabelItemBlock = (props: ILabelItemBlock) => {
: "opacity-0 group-hover:pointer-events-auto group-hover:opacity-100"
} ${isLabelGroup && "-top-0.5"}`}
>
<CustomMenu ellipsis menuButtonOnClick={() => setIsMenuActive(!isMenuActive)} useCaptureForOutsideClick>
<CustomMenu ellipsis>
{customMenuItems.map(
({ isVisible, onClick, CustomIcon, text, key }) =>
isVisible && (
@@ -63,11 +63,12 @@ type Props = {
moduleId: string;
handleClose: () => void;
isArchived?: boolean;
isPeekMode?: boolean;
};
// TODO: refactor this component
export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { moduleId, handleClose, isArchived } = props;
const { moduleId, handleClose, isArchived, isPeekMode = false } = props;
// states
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
@@ -310,7 +311,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
<DeleteModuleModal isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} data={moduleDetails} />
<>
<div
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 ${isPeekMode ? "pt-5" : "pt-20"}`}
>
<div>
<button
@@ -51,6 +51,7 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
moduleId={peekModule?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
isPeekMode
/>
</div>
)}
@@ -1,70 +0,0 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { calculateTimeAgoShort, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useMember } from "@/hooks/store";
// store
import { IPage } from "@/store/pages/page";
type Props = {
page: IPage;
};
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
const { page } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
return (
<div className="flex-shrink-0 relative group/edit-information whitespace-nowrap">
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
<div className="hidden group-hover/edit-information:block absolute z-10 top-full right-0 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg space-y-2">
<div>
<p className="text-xs font-medium text-custom-text-300">Edited by</p>
<Link
href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`}
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
>
<Avatar
src={editorInformation?.avatar}
name={editorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>
{editorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</span>
</span>
</Link>
</div>
<div>
<p className="text-xs font-medium text-custom-text-300">Created by</p>
<Link
href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`}
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
>
<Avatar
src={creatorInformation?.avatar}
name={creatorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>
{creatorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
</span>
</Link>
</div>
</div>
</div>
);
});
@@ -1,2 +1 @@
export * from "./edit-information-popover";
export * from "./quick-actions";
@@ -72,7 +72,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
className="!min-w-[38rem]"
/>
)}
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
<PageInfoPopover page={page} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}
@@ -1,19 +1,19 @@
import { useState } from "react";
import { usePopper } from "react-popper";
import { Info } from "lucide-react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { Calendar, History, Info } from "lucide-react";
// helpers
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
// store
import { IPage } from "@/store/pages/page";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
page: IPage;
};
export const PageInfoPopover: React.FC<Props> = (props) => {
const { editorRef } = props;
const { page } = props;
// states
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
// refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
@@ -21,54 +21,35 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
const secondsToReadableTime = () => {
const wordsCount = editorRef?.documentInfo.words || 0;
const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0));
return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`;
};
const documentInfoCards = [
{
key: "words-count",
title: "Words",
info: editorRef?.documentInfo.words,
},
{
key: "characters-count",
title: "Characters",
info: editorRef?.documentInfo.characters,
},
{
key: "paragraphs-count",
title: "Paragraphs",
info: editorRef?.documentInfo.paragraphs,
},
{
key: "read-time",
title: "Read time",
info: secondsToReadableTime(),
},
];
// derived values
const { created_at, updated_at } = page;
return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
<button type="button" ref={setReferenceElement} className="block">
<Info className="size-3.5" />
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg grid grid-cols-2 gap-1.5"
className="z-10 w-64 space-y-2.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
{documentInfoCards.map((card) => (
<div key={card.key} className="p-2 bg-custom-background-90 rounded">
<h6 className="text-base font-semibold">{card.info}</h6>
<p className="mt-1.5 text-sm text-custom-text-300">{card.title}</p>
</div>
))}
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Last updated on</h6>
<h5 className="flex items-center gap-1 text-sm">
<History className="h-3 w-3" />
{renderFormattedDate(updated_at)}
</h5>
</div>
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Created on</h6>
<h5 className="flex items-center gap-1 text-sm">
<Calendar className="h-3 w-3" />
{renderFormattedDate(created_at)}
</h5>
</div>
</div>
)}
</div>
@@ -1,8 +1,8 @@
"use client";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
import { useParams } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, Link, Lock, LockOpen } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
@@ -11,7 +11,6 @@ import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@pl
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
import { useQueryParams } from "@/hooks/use-query-params";
// store
import { IPage } from "@/store/pages/page";
@@ -24,8 +23,6 @@ type Props = {
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
// router
const router = useRouter();
// store values
const {
archived_at,
@@ -43,8 +40,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId } = useParams();
// page filters
const { isFullWidth, handleFullWidth } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
const handleArchivePage = async () =>
await archive().catch(() =>
@@ -150,23 +145,10 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "version-history",
action: () => {
// add query param, version=current to the route
const updatedRoute = updateQueryParams({
paramsToAdd: { version: "current" },
});
router.push(updatedRoute);
},
label: "Version history",
icon: History,
shouldRender: true,
},
];
return (
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu maxHeight="md" placement="bottom-start" verticalEllipsis closeOnSelect>
<CustomMenu.MenuItem
className="hidden md:flex w-full items-center justify-between gap-2"
onClick={() => handleFullWidth(!isFullWidth)}
+20 -79
View File
@@ -1,23 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane editor
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
// plane types
import { TPage } from "@plane/types";
// plane ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages";
// hooks
import { PageEditorHeaderRoot, PageEditorBody } from "@/components/pages";
import { useProjectPages } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageDescription } from "@/hooks/use-page-description";
import { useQueryParams } from "@/hooks/use-query-params";
// services
import { ProjectPageVersionService } from "@/services/page";
const projectPageVersionService = new ProjectPageVersionService();
// store
import { IPage } from "@/store/pages/page";
type TPageRootProps = {
@@ -27,40 +16,34 @@ type TPageRootProps = {
};
export const PageRoot = observer((props: TPageRootProps) => {
// router
const router = useAppRouter();
const { projectId, workspaceSlug, page } = props;
const { createPage } = useProjectPages();
const { access, description_html, name } = page;
// states
const [editorReady, setEditorReady] = useState(false);
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// search params
const searchParams = useSearchParams();
// store hooks
const { createPage } = useProjectPages();
// derived values
const { access, description_html, name, isContentEditable } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false);
// project-description
const {
handleDescriptionChange,
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
} = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// update query params
const { updateQueryParams } = useQueryParams();
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription(
{
editorRef,
page,
projectId,
workspaceSlug,
}
);
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
@@ -82,50 +65,8 @@ export const PageRoot = observer((props: TPageRootProps) => {
);
};
const version = searchParams.get("version");
useEffect(() => {
if (!version) {
setIsVersionsOverlayOpen(false);
return;
}
setIsVersionsOverlayOpen(true);
}, [version]);
const handleCloseVersionsOverlay = () => {
const updatedRoute = updateQueryParams({
paramsToRemove: ["version"],
});
router.push(updatedRoute);
};
return (
<>
<PageVersionsOverlay
activeVersion={version}
editorComponent={PagesVersionEditor}
fetchAllVersions={async (pageId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchAllVersions(
workspaceSlug.toString(),
projectId.toString(),
pageId
);
}}
fetchVersionDetails={async (pageId, versionId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchVersionById(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
}}
handleRestore={manuallyUpdateDescription}
isOpen={isVersionsOverlayOpen}
onClose={handleCloseVersionsOverlay}
pageId={page.id ?? ""}
restoreEnabled={isContentEditable}
/>
<PageEditorHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
-1
View File
@@ -4,6 +4,5 @@ export * from "./header";
export * from "./list";
export * from "./loaders";
export * from "./modals";
export * from "./version";
export * from "./pages-list-main-content";
export * from "./pages-list-view";
@@ -1,114 +0,0 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane editor
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
// plane types
import { IUserLite, TPageVersion } from "@plane/types";
// plane ui
import { Loader } from "@plane/ui";
// hooks
import { useMember, useMention, usePage, useUser } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
type Props = {
activeVersion: string | null;
isCurrentVersionActive: boolean;
pageId: string;
versionDetails: TPageVersion | undefined;
};
export const PagesVersionEditor: React.FC<Props> = observer((props) => {
const { activeVersion, isCurrentVersionActive, pageId, versionDetails } = props;
// params
const { workspaceSlug, projectId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
const currentPageDetails = usePage(pageId);
// derived values
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// issue-embed
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
// use-mention
const { mentionHighlights } = useMention({
workspaceSlug: workspaceSlug?.toString() ?? "",
projectId: projectId?.toString() ?? "",
members: projectMemberDetails,
user: currentUser ?? undefined,
});
// page filters
const { fontSize, fontStyle } = usePageFilters();
const displayConfig: TDisplayConfig = {
fontSize,
fontStyle,
};
if (!isCurrentVersionActive && !versionDetails)
return (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<Loader.Item width="50%" height="36px" />
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
return (
<DocumentReadOnlyEditorWithRef
id={activeVersion ?? ""}
initialValue={
(isCurrentVersionActive ? currentPageDetails.description_html : versionDetails?.description_html) ?? "<p></p>"
}
containerClassName="p-0 pb-64 border-none"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
/>
);
});
@@ -1,6 +0,0 @@
export * from "./editor";
export * from "./main-content";
export * from "./root";
export * from "./sidebar-list-item";
export * from "./sidebar-list";
export * from "./sidebar-root";
@@ -1,124 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Button, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
type Props = {
activeVersion: string | null;
editorComponent: React.FC<{
activeVersion: string | null;
isCurrentVersionActive: boolean;
pageId: string;
versionDetails: TPageVersion | undefined;
}>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleClose: () => void;
handleRestore: (descriptionHTML: string) => Promise<void>;
pageId: string;
restoreEnabled: boolean;
};
export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
const { activeVersion, editorComponent, fetchVersionDetails, handleClose, handleRestore, pageId, restoreEnabled } =
props;
// states
const [isRestoring, setIsRestoring] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const {
data: versionDetails,
error: versionDetailsError,
mutate: mutateVersionDetails,
} = useSWR(
pageId && activeVersion && activeVersion !== "current" ? `PAGE_VERSION_${activeVersion}` : null,
pageId && activeVersion && activeVersion !== "current" ? () => fetchVersionDetails(pageId, activeVersion) : null
);
const isCurrentVersionActive = activeVersion === "current";
const handleRestoreVersion = async () => {
if (!restoreEnabled) return;
setIsRestoring(true);
await handleRestore(versionDetails?.description_html ?? "<p></p>")
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Page version restored.",
});
handleClose();
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Failed to restore page version.",
})
)
.finally(() => setIsRestoring(false));
};
const handleRetry = async () => {
setIsRetrying(true);
await mutateVersionDetails();
setIsRetrying(false);
};
const VersionEditor = editorComponent;
return (
<div className="flex-grow flex flex-col overflow-hidden">
{versionDetailsError ? (
<div className="flex-grow grid place-items-center">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
<TriangleAlert className="size-10" />
</span>
<div>
<h6 className="text-lg font-semibold">Something went wrong!</h6>
<p className="text-sm text-custom-text-300">The version could not be loaded, please try again.</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : (
<>
<div className="min-h-14 py-3 px-5 border-b border-custom-border-200 flex items-center justify-between gap-2">
<h6 className="text-base font-medium">
{isCurrentVersionActive
? "Current version"
: versionDetails
? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
: "Loading version details"}
</h6>
{!isCurrentVersionActive && restoreEnabled && (
<Button
variant="primary"
size="sm"
className="flex-shrink-0"
onClick={handleRestoreVersion}
loading={isRestoring}
>
{isRestoring ? "Restoring" : "Restore"}
</Button>
)}
</div>
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
<VersionEditor
activeVersion={activeVersion}
isCurrentVersionActive={isCurrentVersionActive}
pageId={pageId}
versionDetails={versionDetails}
/>
</div>
</>
)}
</div>
);
});
@@ -1,70 +0,0 @@
import { observer } from "mobx-react";
// plane types
import { TPageVersion } from "@plane/types";
// components
import { PageVersionsMainContent, PageVersionsSidebarRoot } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
activeVersion: string | null;
editorComponent: React.FC<{
activeVersion: string | null;
isCurrentVersionActive: boolean;
pageId: string;
versionDetails: TPageVersion | undefined;
}>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleRestore: (descriptionHTML: string) => Promise<void>;
isOpen: boolean;
onClose: () => void;
pageId: string;
restoreEnabled: boolean;
};
export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
const {
activeVersion,
editorComponent,
fetchAllVersions,
fetchVersionDetails,
handleRestore,
isOpen,
onClose,
pageId,
restoreEnabled,
} = props;
const handleClose = () => {
onClose();
};
return (
<div
className={cn(
"absolute inset-0 z-10 size-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": isOpen,
}
)}
>
<PageVersionsMainContent
activeVersion={activeVersion}
editorComponent={editorComponent}
fetchVersionDetails={fetchVersionDetails}
handleClose={handleClose}
handleRestore={handleRestore}
pageId={pageId}
restoreEnabled={restoreEnabled}
/>
<PageVersionsSidebarRoot
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
handleClose={handleClose}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
});
@@ -1,48 +0,0 @@
import { observer } from "mobx-react";
import Link from "next/link";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
// hooks
import { useMember } from "@/hooks/store";
type Props = {
href: string;
isActive: boolean;
version: TPageVersion;
};
export const PlaneVersionsSidebarListItem: React.FC<Props> = observer((props) => {
const { href, isActive, version } = props;
// store hooks
const { getUserDetails } = useMember();
// derived values
const ownerDetails = getUserDetails(version.owned_by);
return (
<Link
href={href}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": isActive,
})}
>
<p className="text-sm font-medium truncate">
{renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)}
</p>
<p className="mt-2 flex items-center gap-1 text-xs">
<Avatar
src={ownerDetails?.avatar}
name={ownerDetails?.display_name}
shape="square"
size="sm"
className="flex-shrink-0"
/>
<span className="text-custom-text-300">{ownerDetails?.display_name}</span>
</p>
</Link>
);
});
@@ -1,99 +0,0 @@
import { useState } from "react";
import Link from "next/link";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Button, Loader } from "@plane/ui";
// components
import { PlaneVersionsSidebarListItem } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarList: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, isOpen, pageId } = props;
// states
const [isRetrying, setIsRetrying] = useState(false);
// update query params
const { updateQueryParams } = useQueryParams();
const {
data: versionsList,
error: versionsListError,
mutate: mutateVersionsList,
} = useSWR(
pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null,
pageId && isOpen ? () => fetchAllVersions(pageId) : null
);
const handleRetry = async () => {
setIsRetrying(true);
await mutateVersionsList();
setIsRetrying(false);
};
const getVersionLink = (versionID: string) =>
updateQueryParams({
paramsToAdd: { version: versionID },
});
return (
<div className="mt-4 px-4 h-full flex flex-col space-y-2 overflow-y-scroll vertical-scrollbar scrollbar-sm">
<Link
href={getVersionLink("current")}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": activeVersion === "current",
})}
>
<p className="text-sm font-medium">Current version</p>
</Link>
{versionsListError ? (
<div className="h-full grid place-items-center">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
<TriangleAlert className="size-10" />
</span>
<div>
<h6 className="text-base font-semibold">Something went wrong!</h6>
<p className="text-xs text-custom-text-300">
There was a problem while loading previous
<br />
versions, please try again.
</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : versionsList ? (
versionsList.map((version) => (
<PlaneVersionsSidebarListItem
key={version.id}
href={getVersionLink(version.id)}
isActive={activeVersion === version.id}
version={version}
/>
))
) : (
<Loader className="space-y-4">
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
</Loader>
)}
</div>
);
};
@@ -1,38 +0,0 @@
import { X } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// components
import { PageVersionsSidebarList } from "@/components/pages";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
handleClose: () => void;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarRoot: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props;
return (
<div className="flex-shrink-0 py-4 border-l border-custom-border-200 flex flex-col">
<div className="px-6 flex items-center justify-between gap-2">
<h5 className="text-base font-semibold">Version history</h5>
<button
type="button"
onClick={handleClose}
className="flex-shrink-0 size-6 grid place-items-center text-custom-text-300 hover:text-custom-text-100 transition-colors"
>
<X className="size-4" />
</button>
</div>
<PageVersionsSidebarList
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
};
@@ -28,10 +28,14 @@ export const ProjectFeatureUpdate: FC<Props> = observer((props) => {
return (
<>
<div className="p-2">
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
<div className="px-4 py-2">
<h3 className="text-base font-medium leading-6">Toggle project features</h3>
<div className="text-sm tracking-tight text-custom-text-200 leading-5">
Turn on features which help you manage and run your project.
</div>
</div>
<div className="flex items-center justify-between gap-2 mt-4 px-6 py-4 border-t border-custom-border-100">
<ProjectFeaturesList workspaceSlug={workspaceSlug} projectId={projectId} isAdmin />
<div className="flex items-center justify-between gap-2 mt-4 px-4 pt-4 pb-2 border-t border-custom-border-100">
<div className="flex gap-1 text-sm text-custom-text-300 font-medium">
Congrats! Project <Logo logo={currentProjectDetails.logo_props} />{" "}
<p className="break-all">{currentProjectDetails.name}</p> created.
@@ -61,9 +61,8 @@ export const ProjectFeaturesList: FC<Props> = observer((props) => {
const feature = PROJECT_FEATURES_LIST[featureSectionKey];
return (
<div key={featureSectionKey} className="">
<div className="flex flex-col justify-center border-b border-custom-border-100 py-3">
<div className="flex items-center border-b border-custom-border-100 py-3.5">
<h3 className="text-xl font-medium">{feature.title}</h3>
<h4 className="text-sm leading-5 text-custom-text-200">{feature.description}</h4>
</div>
{Object.keys(feature.featureList).map((featureItemKey) => {
const featureItem = feature.featureList[featureItemKey];
+8 -13
View File
@@ -7,16 +7,17 @@ import { TViewFiltersSortBy, TViewFiltersSortKey } from "@plane/types";
import { CustomMenu, getButtonStyling } from "@plane/ui";
// constants
import { VIEW_SORTING_KEY_OPTIONS } from "@/constants/views";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
onChange: (value: { key?: TViewFiltersSortKey; order?: TViewFiltersSortBy }) => void;
sortBy: TViewFiltersSortBy;
sortKey: TViewFiltersSortKey;
isMobile?: boolean;
};
export const ViewOrderByDropdown: React.FC<Props> = (props) => {
const { onChange, sortBy, sortKey, isMobile = false } = props;
const { onChange, sortBy, sortKey } = props;
const orderByDetails = VIEW_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
const isDescending = sortBy === "desc";
@@ -26,20 +27,14 @@ export const ViewOrderByDropdown: React.FC<Props> = (props) => {
{ key: "desc", label: "Descending", isSelected: isDescending },
];
const buttonClassName = isMobile
? "flex items-center text-sm text-custom-text-200"
: `${getButtonStyling("neutral-primary", "sm")} px-2 text-custom-text-300`;
const chevronClassName = isMobile ? "h-4 w-4 text-custom-text-200" : "h-3 w-3";
return (
<CustomMenu
customButton={
<span className={buttonClassName}>
{!isMobile && <ArrowDownWideNarrow className="h-3 w-3" />}
<span className="flex-shrink-0"> {orderByDetails?.label}</span>
<ChevronDown className={chevronClassName} strokeWidth={2} />
</span>
<div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
<ArrowDownWideNarrow className="h-3 w-3" />
{orderByDetails?.label}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
}
placement="bottom-end"
maxHeight="lg"
+19 -21
View File
@@ -89,28 +89,26 @@ export const ViewListHeader = observer(() => {
)}
</div>
</div>
<div className="hidden md:flex items-center gap-2">
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</FiltersDropdown>
</div>
);
});
@@ -1,9 +1,6 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useAppTheme } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = {
projectId: string | null;
@@ -13,11 +10,8 @@ type Props = {
isSidebarCollapsed: boolean;
};
export const FavoriteItemTitle: FC<Props> = observer((props) => {
export const FavoriteItemTitle: FC<Props> = (props) => {
const { projectId, href, title, icon, isSidebarCollapsed } = props;
// store hooks
const { toggleSidebar } = useAppTheme();
const { isMobile } = usePlatformOS();
const linkClass = "flex items-center gap-1.5 truncate w-full";
const collapsedClass =
@@ -28,7 +22,6 @@ export const FavoriteItemTitle: FC<Props> = observer((props) => {
const projectItem = document.getElementById(`${projectId}`);
projectItem?.scrollIntoView({ behavior: "smooth" });
}
if (isMobile) toggleSidebar();
};
return (
@@ -37,4 +30,4 @@ export const FavoriteItemTitle: FC<Props> = observer((props) => {
{!isSidebarCollapsed && <span className="text-sm leading-5 font-medium flex-1 truncate">{title}</span>}
</Link>
);
});
};
@@ -8,7 +8,7 @@ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/el
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import { createRoot } from "react-dom/client";
import {
PenSquare,
@@ -35,7 +35,6 @@ import {
DropIndicator,
DragHandle,
Intake,
ControlLink,
} from "@plane/ui";
// components
import { Logo } from "@/components/common";
@@ -127,8 +126,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const projectRef = useRef<HTMLDivElement | null>(null);
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router
const router = useRouter();
// router params
const { workspaceSlug, projectId: URLProjectId } = useParams();
// pathname
const pathname = usePathname();
@@ -283,16 +281,11 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
else setIsProjectListOpen(false);
}, [URLProjectId]);
const handleItemClick = () => {
if (!isProjectListOpen && !isMobile) router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
setIsProjectListOpen((prev) => !prev);
};
return (
<>
<PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} />
<LeaveProjectModal project={project} isOpen={leaveProjectModalOpen} onClose={() => setLeaveProjectModal(false)} />
<Disclosure key={`${project.id}_${URLProjectId}`} ref={projectRef} defaultOpen={isProjectListOpen} as="div">
<Disclosure key={`${project.id}_${URLProjectId}`} ref={projectRef} defaultOpen={isProjectListOpen}>
<div
id={`sidebar-${projectId}-${projectListType}`}
className={cn("relative", {
@@ -335,19 +328,22 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</Tooltip>
)}
{isSidebarCollapsed ? (
<ControlLink
<Link
href={`/${workspaceSlug}/projects/${project.id}/issues`}
className={cn("flex-grow flex items-center gap-1.5 truncate text-left select-none", {
"justify-center": isSidebarCollapsed,
})}
onClick={handleItemClick}
>
<Disclosure.Button as="button" className="size-8 aspect-square flex-shrink-0 grid place-items-center">
<Disclosure.Button
as="button"
className="size-8 aspect-square flex-shrink-0 grid place-items-center"
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
</Disclosure.Button>
</ControlLink>
</Link>
) : (
<>
<Tooltip
@@ -356,24 +352,21 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
disabled={!isSidebarCollapsed}
isMobile={isMobile}
>
<ControlLink
href={`/${workspaceSlug}/projects/${project.id}/issues`}
className="flex-grow flex truncate"
onClick={handleItemClick}
>
<Link href={`/${workspaceSlug}/projects/${project.id}/issues`} className="flex-grow flex truncate">
<Disclosure.Button
as="button"
type="button"
className={cn("flex-grow flex items-center gap-1.5 text-left select-none w-full", {
"justify-center": isSidebarCollapsed,
})}
onClick={() => setIsProjectListOpen(!isProjectListOpen)}
>
<div className="size-4 grid place-items-center flex-shrink-0">
<Logo logo={project.logo_props} size={16} />
</div>
<p className="truncate text-sm font-medium text-custom-sidebar-text-200">{project.name}</p>
</Disclosure.Button>
</ControlLink>
</Link>
</Tooltip>
<CustomMenu
customButton={
@@ -393,7 +386,6 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
)}
customButtonClassName="grid place-items-center"
placement="bottom-start"
useCaptureForOutsideClick
>
{!isViewerOrGuest && (
<CustomMenu.MenuItem
-11
View File
@@ -3,8 +3,6 @@
import { useCallback, useEffect, useMemo } from "react";
// hooks
import { useMultipleSelectStore } from "@/hooks/store";
//
import useReloadConfirmations from "./use-reload-confirmation";
export type TEntityDetails = {
entityID: string;
@@ -54,15 +52,6 @@ export const useMultipleSelect = (props: Props) => {
getEntityDetailsFromEntityID,
} = useMultipleSelectStore();
useReloadConfirmations(
selectedEntityIds && selectedEntityIds.length > 0,
"Are you sure you want to leave? Your current bulk operation selections will be lost.",
true,
() => {
clearSelection();
}
);
const groups = useMemo(() => Object.keys(entities), [entities]);
const entitiesList: TEntityDetails[] = useMemo(
+3 -16
View File
@@ -1,19 +1,20 @@
import React, { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
// plane editor
import {
EditorRefApi,
proseMirrorJSONToBinaryString,
applyUpdates,
generateJSONfromHTMLForDocumentEditor,
} from "@plane/editor";
// hooks
import { setToast, TOAST_TYPE } from "@plane/ui";
import useAutoSave from "@/hooks/use-auto-save";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { ProjectPageService } from "@/services/page";
// store
import { IPage } from "@/store/pages/page";
const projectPageService = new ProjectPageService();
@@ -182,19 +183,6 @@ export const usePageDescription = (props: Props) => {
]
);
const manuallyUpdateDescription = async (descriptionHTML: string) => {
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
try {
editorRef.current?.clearEditor(true);
await updateDescription(yDocBinaryString, descriptionHTML ?? "<p></p>");
await mutateDescriptionYJS();
} catch (error) {
console.log("error", error);
}
};
useAutoSave(handleSaveDescription);
return {
@@ -202,6 +190,5 @@ export const usePageDescription = (props: Props) => {
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
};
};
-39
View File
@@ -1,39 +0,0 @@
import { useSearchParams, usePathname } from "next/navigation";
type TParamsToAdd = {
[key: string]: string;
};
export const useQueryParams = () => {
// next navigation
const searchParams = useSearchParams();
const pathname = usePathname();
const updateQueryParams = ({
paramsToAdd = {},
paramsToRemove = [],
}: {
paramsToAdd?: TParamsToAdd;
paramsToRemove?: string[];
}) => {
const currentParams = new URLSearchParams(searchParams.toString());
// add or update query parameters
Object.keys(paramsToAdd).forEach((key) => {
currentParams.set(key, paramsToAdd[key]);
});
// remove specified query parameters
paramsToRemove.forEach((key) => {
currentParams.delete(key);
});
// construct the new route with the updated query parameters
const newRoute = `${pathname}?${currentParams.toString()}`;
return newRoute;
};
return {
updateQueryParams,
};
};

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