Merge pull request #945 from makeplane/uat

release: v1.3.0-rc38
This commit is contained in:
sriram veeraghanta
2024-08-27 13:46:58 +05:30
committed by GitHub
85 changed files with 2632 additions and 247 deletions

View File

@@ -0,0 +1,29 @@
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>
);
};

View File

@@ -1,3 +1,4 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";

View File

@@ -8,8 +8,16 @@ 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
@@ -53,6 +61,7 @@ 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 }));
@@ -91,6 +100,15 @@ 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">
@@ -103,7 +121,11 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
<form
className="space-y-4"

View File

@@ -92,6 +92,7 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (

View File

@@ -171,7 +171,40 @@ class PageLogSerializer(BaseSerializer):
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
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",
]
read_only_fields = [
"workspace",
"page",

View File

@@ -5,16 +5,18 @@ from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -25,7 +27,7 @@ class PageVersionEndpoint(BaseAPIView):
pk=pk,
)
# Serialize the page version
serializer = PageVersionSerializer(page_version)
serializer = PageVersionDetailSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(

View File

@@ -614,8 +614,8 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
project.archived_at = timezone.now()
project.save()
UserFavorite.objects.filter(
project_id=project_id,
workspace__slug=slug,
project=project_id,
).delete()
return Response(
{"archived_at": str(project.archived_at)},

View File

@@ -13,7 +13,6 @@ from plane.app.permissions import allow_permission, ROLE
class WorkspaceFavoriteEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
@@ -76,7 +75,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
class WorkspaceFavoriteGroupEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)

View File

@@ -1,13 +1,11 @@
# Python imports
import json
import requests
# Third Party imports
from celery import shared_task
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone

View File

@@ -18,11 +18,9 @@ from django.core import serializers
# Module imports
from plane.graphql.permissions.project import (
ProjectMemberPermission,
ProjectBasePermission,
)
from plane.db.models import (
CycleIssue,
Cycle,
)
from plane.db.models import CycleIssue, Cycle, UserFavorite
from plane.graphql.bgtasks.issue_activity_task import issue_activity
@@ -134,7 +132,10 @@ class CycleIssueMutation:
issue: strawberry.ID,
) -> bool:
cycle_issue = await sync_to_async(CycleIssue.objects.filter)(
cycle_id=cycle, project_id=project, workspace__slug=slug, issue_id=issue
cycle_id=cycle,
project_id=project,
workspace__slug=slug,
issue_id=issue,
)
await sync_to_async(issue_activity.delay)(
type="cycle.activity.deleted",
@@ -155,3 +156,46 @@ class CycleIssueMutation:
await sync_to_async(cycle_issue.delete)()
return True
@strawberry.type
class CycleFavoriteMutation:
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def favoriteCycle(
self,
info: Info,
slug: str,
project: strawberry.ID,
cycle: strawberry.ID,
) -> bool:
_ = await sync_to_async(UserFavorite.objects.create)(
entity_identifier=cycle,
entity_type="cycle",
user=info.context.user,
project_id=project,
)
return True
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def unFavoriteCycle(
self,
info: Info,
slug: str,
project: strawberry.ID,
cycle: strawberry.ID,
) -> bool:
cycle_favorite = await sync_to_async(UserFavorite.objects.get)(
entity_identifier=cycle,
entity_type="cycle",
user=info.context.user,
workspace__slug=slug,
project_id=project,
)
await sync_to_async(cycle_favorite.delete)()
return True

View File

@@ -0,0 +1,61 @@
# Strawberry imports
import strawberry
from strawberry.types import Info
from strawberry.permission import PermissionExtension
# Third-party imports
from asgiref.sync import sync_to_async
# Django imports
from typing import Optional
# Module imports
from plane.graphql.permissions.workspace import (
WorkspaceBasePermission,
)
from plane.db.models import Workspace, UserFavorite
@strawberry.type
class UserFavoriteMutation:
@strawberry.mutation(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def createUserFavorite(
self,
info: Info,
slug: str,
entity_identifier: strawberry.ID,
entity_type: str,
project: Optional[strawberry.ID] = None,
) -> bool:
workspace = await sync_to_async(Workspace.objects.get)(slug=slug)
_ = await sync_to_async(UserFavorite.objects.create)(
entity_identifier=entity_identifier,
entity_type=entity_type,
user=info.context.user,
project_id=project,
workspace_id=workspace.id,
)
return True
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[WorkspaceBasePermission()])]
)
async def deleteUserFavorite(
self,
info: Info,
slug: str,
favorite: strawberry.ID,
) -> bool:
user_favorite = await sync_to_async(UserFavorite.objects.get)(
pk=favorite,
user=info.context.user,
workspace__slug=slug,
)
await sync_to_async(user_favorite.delete)()
return True

View File

@@ -11,10 +11,9 @@ from strawberry.permission import PermissionExtension
from typing import Optional
from asgiref.sync import sync_to_async
# Django imports
# Module imports
from plane.graphql.types.issue import IssueType, IssueUserPropertyType
from plane.graphql.types.issue import IssuesType, IssueUserPropertyType
from plane.graphql.permissions.project import (
ProjectBasePermission,
ProjectMemberPermission,
@@ -26,6 +25,7 @@ from plane.db.models import (
IssueLabel,
Workspace,
IssueAttachment,
IssueSubscriber,
)
@@ -51,7 +51,7 @@ class IssueMutation:
estimatePoint: Optional[str] = None,
startDate: Optional[datetime] = None,
targetDate: Optional[datetime] = None,
) -> IssueType:
) -> IssuesType:
workspace = await sync_to_async(Workspace.objects.get)(slug=slug)
issue = await sync_to_async(Issue.objects.create)(
name=name,
@@ -129,14 +129,14 @@ class IssueMutation:
name: Optional[str] = None,
state: Optional[str] = None,
priority: Optional[str] = None,
labels: list[strawberry.ID] = None,
assignees: list[strawberry.ID] = None,
labels: Optional[list[strawberry.ID]] = None,
assignees: Optional[list[strawberry.ID]] = None,
description: Optional[str] = None,
parent: Optional[str] = None,
estimatePoint: Optional[str] = None,
startDate: Optional[datetime] = None,
targetDate: Optional[datetime] = None,
) -> IssueType:
) -> IssuesType:
issue = await sync_to_async(Issue.objects.get)(id=id)
if name is not None:
@@ -262,13 +262,6 @@ class IssueUserPropertyMutation:
@strawberry.type
class IssueAttachmentMutation:
# @strawberry.field(
# extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
# )
# async def create_issue_attachment(
# ) -> IssueAttachment:
# pass
# @strawberry.mutation(
# extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
@@ -276,12 +269,17 @@ class IssueAttachmentMutation:
# def upload_file(self, file: Upload, info: Info) -> bool:
# content = file.read()
# filename = file.filename
# @strawberry.mutation(
# extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
# )
# async def upload_file(self, file: Upload, info: Info) -> bool:
# content = await sync_to_async(file.read)()
# filename = file.filename
# # Save the file using Django's file storage
# # file_name = default_storage.save(filename, content)
# return True
# # Save the file using Django's file storage
# await sync_to_async(default_storage.save)(filename, content)
# return True
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
@@ -294,7 +292,6 @@ class IssueAttachmentMutation:
issue: strawberry.ID,
attachment: strawberry.ID,
) -> bool:
issue_attachment = await sync_to_async(IssueAttachment.objects.get)(
id=attachment,
issue_id=issue,
@@ -303,3 +300,40 @@ class IssueAttachmentMutation:
)
await sync_to_async(issue_attachment.delete)()
return True
@strawberry.type
class IssueSubscriptionMutation:
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def subscribeIssue(
self,
info: Info,
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
) -> bool:
issue = await sync_to_async(IssueSubscriber.objects.create)(
issue_id=issue, project_id=project, subscriber=info.context.user
)
return True
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def unSubscribeIssue(
self,
info: Info,
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
) -> bool:
issue_subscriber = await sync_to_async(IssueSubscriber.objects.get)(
issue_id=issue,
subscriber=info.context.user,
project_id=project,
workspace__slug=slug,
)
await sync_to_async(issue_subscriber.delete)()
return True

View File

@@ -0,0 +1,67 @@
# Third-Party Imports
import strawberry
# Python Standard Library Imports
from asgiref.sync import sync_to_async
# Strawberry Imports
from strawberry.types import Info
from strawberry.permission import PermissionExtension
# Module Imports
from plane.db.models import (
IssueLink,
)
from plane.graphql.types.link import IssueLinkType
from plane.graphql.permissions.project import ProjectBasePermission
# from plane.graphql.utils.issue import issue_activity
@strawberry.type
class IssueLinkMutation:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def createIssueLink(
self,
info: Info,
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
url: str,
title: str,
) -> IssueLinkType:
if not url.startswith(("http://", "https://")):
raise ValueError("Invalid URL")
if await sync_to_async(
IssueLink.objects.filter(
url=url,
issue_id=issue,
).exists
)():
raise ValueError("Issue link already exists")
issue_links = await sync_to_async(IssueLink.objects.create)(
issue_id=issue,
project_id=project,
url=url,
title=title,
)
# await sync_to_async(
# issue_activity.delay(
# type="link.activity.created",
# requested_data=json.dumps(issue_links),
# actor_id=str(info.context.user.id),
# issue_id=str(issue),
# project_id=str(project.id),
# current_instance=None,
# epoch=int(timezone.now().timestamp()),
# notification=True,
# origin=info.context.request.META.get("HTTP_ORIGIN"),
# )
# )()
return issue_links

View File

@@ -16,11 +16,9 @@ from django.utils import timezone
# Module imports
from plane.graphql.permissions.project import (
ProjectMemberPermission,
ProjectBasePermission,
)
from plane.db.models import (
Project,
ModuleIssue,
)
from plane.db.models import Project, ModuleIssue, UserFavorite
from plane.graphql.bgtasks.issue_activity_task import issue_activity
@@ -39,9 +37,7 @@ class ModuleIssueMutation:
module: strawberry.ID,
issues: JSON,
) -> bool:
project = await sync_to_async(Project.objects.get)(pk=project)
print("issue", issues)
# Create ModuleIssues asynchronously
await sync_to_async(
lambda: ModuleIssue.objects.bulk_create(
@@ -115,3 +111,46 @@ class ModuleIssueMutation:
await sync_to_async(module_issue.delete)()
return True
@strawberry.type
class ModuleFavoriteMutation:
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def favoriteModule(
self,
info: Info,
slug: str,
project: strawberry.ID,
module: strawberry.ID,
) -> bool:
_ = await sync_to_async(UserFavorite.objects.create)(
entity_identifier=module,
entity_type="module",
user=info.context.user,
project_id=project,
)
return True
@strawberry.mutation(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def unFavoriteModule(
self,
info: Info,
slug: str,
project: strawberry.ID,
module: strawberry.ID,
) -> bool:
module_favorite = await sync_to_async(UserFavorite.objects.get)(
entity_identifier=module,
entity_type="module",
user=info.context.user,
workspace__slug=slug,
project_id=project,
)
await sync_to_async(module_favorite.delete)()
return True

View File

@@ -1,18 +1,34 @@
# Python imports
from typing import Optional
# Third-Party Imports
import strawberry
# Python Standard Library Imports
from asgiref.sync import sync_to_async
# Django Imports
from django.utils import timezone
from django.db.models import Q
# Strawberry Imports
from strawberry.types import Info
from strawberry.scalars import JSON
from strawberry.permission import PermissionExtension
# Module Imports
from plane.db.models import Cycle, Issue
from plane.graphql.types.cycle import CycleType
from plane.graphql.types.issue import IssueType
from plane.graphql.types.issue import (
IssuesInformationType,
IssuesInformationObjectType,
IssuesType,
)
from plane.graphql.permissions.project import ProjectBasePermission
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.issue_filters import issue_filters
from plane.graphql.utils.paginator import paginate
from plane.graphql.utils.issue import issue_information_query_execute
@strawberry.type
@@ -26,14 +42,25 @@ class CycleQuery:
slug: str,
project: strawberry.ID,
) -> list[CycleType]:
# get cycles those are current and upcoming cycles based on the start_date and end_date
cycles = await sync_to_async(list)(
Cycle.objects.filter(workspace__slug=slug)
.filter(project_id=project)
Cycle.objects.filter(workspace__slug=slug, project_id=project)
.filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)
.filter(
Q(start_date__isnull=True, end_date__isnull=True)
| Q(
start_date__lte=timezone.now().date(),
end_date__gte=timezone.now().date(),
)
| (
Q(start_date__isnull=False)
& Q(start_date__gte=timezone.now().date())
)
)
.order_by("start_date")
)
return cycles
@@ -57,6 +84,86 @@ class CycleQuery:
return cycle
# cycle issues information query
@strawberry.type
class CycleIssuesInformationQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def cycleIssuesInformation(
self,
info: Info,
slug: str,
project: strawberry.ID,
cycle: strawberry.ID,
filters: Optional[JSON] = {},
groupBy: Optional[str] = None,
orderBy: Optional[str] = "-created_at",
) -> IssuesInformationType:
filters = issue_filters(filters, "POST")
# all issues tab information
(
all_issue_count,
all_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
cycle=cycle,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# active issues tab information
filters["state__group__in"] = ["unstarted", "started"]
(
active_issue_count,
active_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
cycle=cycle,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# backlog issues tab information
filters["state__group__in"] = ["backlog"]
(
backlog_issue_count,
backlog_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
cycle=cycle,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
issue_information = IssuesInformationType(
all=IssuesInformationObjectType(
totalIssues=all_issue_count, groupInfo=all_issue_group_info
),
active=IssuesInformationObjectType(
totalIssues=active_issue_count,
groupInfo=active_issue_group_info,
),
backlog=IssuesInformationObjectType(
totalIssues=backlog_issue_count,
groupInfo=backlog_issue_group_info,
),
)
return issue_information
# cycle issues
@strawberry.type
class CycleIssueQuery:
@strawberry.field(
@@ -68,15 +175,32 @@ class CycleIssueQuery:
slug: str,
project: strawberry.ID,
cycle: strawberry.ID,
) -> list[IssueType]:
filters: Optional[JSON] = {},
orderBy: Optional[str] = "-created_at",
cursor: Optional[str] = None,
type: Optional[str] = "all",
) -> PaginatorResponse[IssuesType]:
filters = issue_filters(filters, "POST")
# Filter issues based on the type
if type == "backlog":
filters["state__group__in"] = ["backlog"]
elif type == "active":
filters["state__group__in"] = ["unstarted", "started"]
cycles_issues = await sync_to_async(list)(
Issue.issue_objects.filter(workspace__slug=slug)
.filter(project_id=project)
.filter(issue_cycle__cycle_id=cycle)
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project,
issue_cycle__cycle_id=cycle,
)
.filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by(orderBy, "-created_at")
.filter(**filters)
)
return cycles_issues
return paginate(results_object=cycles_issues, cursor=cursor)

View File

@@ -0,0 +1,28 @@
from asgiref.sync import sync_to_async
from strawberry.permission import PermissionExtension
from plane.db.models import EstimatePoint
from plane.graphql.types.estimate import EstimatePointType
from plane.graphql.permissions.project import ProjectBasePermission
from strawberry.types import Info
import strawberry
@strawberry.type
class EstimatePointQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def estimatePoints(
self,
info: Info,
slug: str,
project: strawberry.ID,
) -> list[EstimatePointType]:
estimate_points = await sync_to_async(list)(
EstimatePoint.objects.filter(
workspace__slug=slug, project_id=project
).order_by("-created_at")
)
return estimate_points

View File

@@ -15,10 +15,13 @@ from django.db.models import Prefetch, Q
# Module Imports
from plane.graphql.types.issue import (
IssueType,
IssuesInformationType,
IssuesInformationObjectType,
IssuesType,
IssueUserPropertyType,
IssueCommentActivityType,
IssuePropertyActivityType,
IssueTypesType,
)
from plane.db.models import (
Issue,
@@ -26,12 +29,92 @@ from plane.db.models import (
IssueUserProperty,
IssueComment,
CommentReaction,
IssueType,
)
from plane.graphql.utils.issue_filters import issue_filters
from plane.graphql.permissions.workspace import WorkspaceBasePermission
from plane.graphql.permissions.project import ProjectBasePermission
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.paginator import paginate
from plane.graphql.utils.issue import issue_information_query_execute
# issues information query
@strawberry.type
class IssuesInformationQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def issuesInformation(
self,
info: Info,
slug: str,
project: strawberry.ID,
filters: Optional[JSON] = {},
groupBy: Optional[str] = None,
orderBy: Optional[str] = "-created_at",
) -> IssuesInformationType:
filters = issue_filters(filters, "POST")
# all issues tab information
(
all_issue_count,
all_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# active issues tab information
filters["state__group__in"] = ["unstarted", "started"]
(
active_issue_count,
active_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# backlog issues tab information
filters["state__group__in"] = ["backlog"]
(
backlog_issue_count,
backlog_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
issue_information = IssuesInformationType(
all=IssuesInformationObjectType(
totalIssues=all_issue_count, groupInfo=all_issue_group_info
),
active=IssuesInformationObjectType(
totalIssues=active_issue_count,
groupInfo=active_issue_group_info,
),
backlog=IssuesInformationObjectType(
totalIssues=backlog_issue_count,
groupInfo=backlog_issue_group_info,
),
)
return issue_information
# issues query
@strawberry.type
class IssueQuery:
@strawberry.field(
@@ -44,12 +127,12 @@ class IssueQuery:
project: strawberry.ID,
filters: Optional[JSON] = {},
orderBy: Optional[str] = "-created_at",
groupBy: Optional[str] = None,
cursor: Optional[str] = None,
type: Optional[str] = "all",
) -> list[IssueType]:
) -> PaginatorResponse[IssuesType]:
filters = issue_filters(filters, "POST")
# Filter issues based on the type
if type == "backlog":
filters["state__group__in"] = ["backlog"]
elif type == "active":
@@ -68,7 +151,8 @@ class IssueQuery:
.order_by(orderBy, "-created_at")
.filter(**filters)
)
return issues
return paginate(results_object=issues, cursor=cursor)
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
@@ -79,7 +163,7 @@ class IssueQuery:
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
) -> IssueType:
) -> IssuesType:
issue = await sync_to_async(Issue.issue_objects.get)(
workspace__slug=slug,
project_id=project,
@@ -97,7 +181,7 @@ class RecentIssuesQuery:
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def recent_issues(self, info: Info, slug: str) -> list[IssueType]:
async def recent_issues(self, info: Info, slug: str) -> list[IssuesType]:
# Fetch the top 5 recent issue IDs from the activity table
issue_ids_coroutine = sync_to_async(list)(
IssueActivity.objects.filter(
@@ -121,9 +205,7 @@ class RecentIssuesQuery:
).filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)[
:5
]
)[:5]
)
return issues
@@ -139,9 +221,9 @@ class IssueUserPropertyQuery:
info: Info,
slug: str,
project: strawberry.ID,
) -> list[IssueUserPropertyType]:
issue_properties = await sync_to_async(list)(
IssueUserProperty.objects.filter(
) -> IssueUserPropertyType:
issue_property = await sync_to_async(
lambda: IssueUserProperty.objects.filter(
workspace__slug=slug, project_id=project
)
.filter(
@@ -149,8 +231,10 @@ class IssueUserPropertyQuery:
project__project_projectmember__is_active=True,
)
.order_by("-created_at")
)
return issue_properties
.first()
)()
return issue_property
@strawberry.type
@@ -231,8 +315,9 @@ class WorkspaceIssuesQuery:
slug: str,
filters: Optional[JSON] = {},
orderBy: Optional[str] = "-created_at",
) -> list[IssueType]:
issues = await sync_to_async(list)(
cursor: Optional[str] = None,
) -> list[IssuesType]:
workspace_issues = await sync_to_async(list)(
Issue.issue_objects.filter(
project__project_projectmember__member=info.context.user,
project__projectmember__is_active=True,
@@ -242,4 +327,53 @@ class WorkspaceIssuesQuery:
.order_by(orderBy, "-created_at")
.filter(**filters)
)
return issues
return paginate(results_object=workspace_issues, cursor=cursor)
@strawberry.type
class SubIssuesQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def sub_issues(
self,
info: Info,
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
cursor: Optional[str] = None,
) -> PaginatorResponse[IssuesType]:
sub_issues = await sync_to_async(list)(
Issue.issue_objects.filter(
workspace__slug=slug,
parent_id=issue,
)
.filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("-created_at")
)
return paginate(results_object=sub_issues, cursor=cursor)
@strawberry.type
class IssueTypesTypeQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[WorkspaceBasePermission()])]
)
async def issueTypes(
self, info: Info, slug: str
) -> list[IssueTypesType]:
issue_types = await sync_to_async(list)(
IssueType.objects.filter(
workspace__slug=slug
)
.distinct()
)
return issue_types

View File

@@ -0,0 +1,35 @@
# Third-Party Imports
import strawberry
# Python Standard Library Imports
from asgiref.sync import sync_to_async
# Strawberry Imports
from strawberry.types import Info
from strawberry.permission import PermissionExtension
# Module Imports
from plane.db.models import IssueLink
from plane.graphql.types.link import IssueLinkType
from plane.graphql.permissions.project import ProjectBasePermission
@strawberry.type
class IssueLinkQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def issueLink(
self,
info: Info,
slug: str,
project: strawberry.ID,
issue: strawberry.ID,
) -> list[IssueLinkType]:
issue_links = await sync_to_async(list)(
IssueLink.objects.filter(
issue_id=issue, workspace__slug=slug, project_id=project
).order_by("-created_at")
)
return issue_links

View File

@@ -1,3 +1,6 @@
# Python imports
from typing import Optional
# Third-Party Imports
import strawberry
@@ -6,13 +9,22 @@ from asgiref.sync import sync_to_async
# Strawberry Imports
from strawberry.types import Info
from strawberry.scalars import JSON
from strawberry.permission import PermissionExtension
# Module Imports
from plane.db.models import Module, Issue
from plane.graphql.types.module import ModuleType
from plane.graphql.types.issue import IssueType
from plane.graphql.types.issue import (
IssuesInformationType,
IssuesInformationObjectType,
IssuesType,
)
from plane.graphql.permissions.project import ProjectBasePermission
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.issue_filters import issue_filters
from plane.graphql.utils.paginator import paginate
from plane.graphql.utils.issue import issue_information_query_execute
@strawberry.type
@@ -25,8 +37,8 @@ class ModuleQuery:
info: Info,
slug: str,
project: strawberry.ID,
) -> list[ModuleType]:
cursor: Optional[str] = None,
) -> PaginatorResponse[ModuleType]:
modules = await sync_to_async(list)(
Module.objects.filter(workspace__slug=slug)
.filter(project_id=project)
@@ -35,7 +47,8 @@ class ModuleQuery:
project__project_projectmember__is_active=True,
)
)
return modules
return paginate(results_object=modules, cursor=cursor)
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
@@ -56,7 +69,110 @@ class ModuleQuery:
)
return module
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def moduleIds(
self,
info: Info,
slug: str,
project: strawberry.ID,
moduleIds: list[strawberry.ID],
) -> list[ModuleType]:
modules = await sync_to_async(list)(
Module.objects.filter(workspace__slug=slug)
.filter(
project_id=project,
id__in=moduleIds,
)
.filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)
)
return modules
# module issues information query
@strawberry.type
class ModuleIssuesInformationQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def moduleIssuesInformation(
self,
info: Info,
slug: str,
project: strawberry.ID,
module: strawberry.ID,
filters: Optional[JSON] = {},
groupBy: Optional[str] = None,
orderBy: Optional[str] = "-created_at",
) -> IssuesInformationType:
filters = issue_filters(filters, "POST")
# all issues tab information
(
all_issue_count,
all_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
module=module,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# active issues tab information
filters["state__group__in"] = ["unstarted", "started"]
(
active_issue_count,
active_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
module=module,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
# backlog issues tab information
filters["state__group__in"] = ["backlog"]
(
backlog_issue_count,
backlog_issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
project=project,
module=module,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
issue_information = IssuesInformationType(
all=IssuesInformationObjectType(
totalIssues=all_issue_count, groupInfo=all_issue_group_info
),
active=IssuesInformationObjectType(
totalIssues=active_issue_count,
groupInfo=active_issue_group_info,
),
backlog=IssuesInformationObjectType(
totalIssues=backlog_issue_count,
groupInfo=backlog_issue_group_info,
),
)
return issue_information
# module issues
@strawberry.type
class ModuleIssueQuery:
@strawberry.field(
@@ -68,15 +184,33 @@ class ModuleIssueQuery:
slug: str,
project: strawberry.ID,
module: strawberry.ID,
) -> list[IssueType]:
filters: Optional[JSON] = {},
orderBy: Optional[str] = "-created_at",
cursor: Optional[str] = None,
type: Optional[str] = "all",
) -> PaginatorResponse[IssuesType]:
filters = issue_filters(filters, "POST")
# Filter issues based on the type
if type == "backlog":
filters["state__group__in"] = ["backlog"]
elif type == "active":
filters["state__group__in"] = ["unstarted", "started"]
module_issues = await sync_to_async(list)(
Issue.issue_objects.filter(workspace__slug=slug)
.filter(project_id=project)
.filter(issue_module__module_id=module)
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project,
issue_module__module_id=module,
)
.filter(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by(orderBy, "-created_at")
.filter(**filters)
)
return module_issues
return paginate(results_object=module_issues, cursor=cursor)

View File

@@ -1,3 +1,6 @@
# Python imports
from typing import Optional
# Third-Party Imports
import strawberry
from asgiref.sync import sync_to_async
@@ -12,18 +15,60 @@ from django.db.models import Exists, OuterRef, Q
# Module Imports
from plane.graphql.types.page import PageType
from plane.db.models import UserFavorite, Page
from plane.graphql.permissions.project import ProjectBasePermission
from plane.graphql.permissions.workspace import WorkspaceBasePermission
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.paginator import paginate
@strawberry.type
class UserPageQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[WorkspaceBasePermission()])]
)
async def userPages(
self,
info: Info,
slug: str,
cursor: Optional[str] = None,
) -> PaginatorResponse[PageType]:
subquery = UserFavorite.objects.filter(
user=info.context.user,
entity_type="page",
entity_identifier=OuterRef("pk"),
workspace__slug=slug,
)
pages = await sync_to_async(list)(
Page.objects.filter(workspace__slug=slug)
.filter(
projects__project_projectmember__member=info.context.user,
projects__project_projectmember__is_active=True,
projects__archived_at__isnull=True,
)
.filter(parent__isnull=True)
.filter(Q(owned_by=info.context.user))
.select_related("workspace", "owned_by")
.prefetch_related("projects")
.annotate(is_favorite=Exists(subquery))
)
return paginate(results_object=pages, cursor=cursor)
@strawberry.type
class PageQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def pages(
self, info: Info, slug: str, project: strawberry.ID
) -> list[PageType]:
self,
info: Info,
slug: str,
project: strawberry.ID,
cursor: Optional[str] = None,
) -> PaginatorResponse[PageType]:
subquery = UserFavorite.objects.filter(
user=info.context.user,
entity_type="page",
@@ -43,10 +88,13 @@ class PageQuery:
.prefetch_related("projects")
.annotate(is_favorite=Exists(subquery))
)
return pages
return paginate(results_object=pages, cursor=cursor)
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def page(
self,

View File

@@ -8,32 +8,47 @@ from strawberry.permission import PermissionExtension
# Django Imports
from django.db.models import Exists, OuterRef, Q
from typing import Optional
# Module Imports
from plane.graphql.types.project import ProjectType, ProjectMemberType
from plane.db.models import Project, ProjectMember, UserFavorite
from plane.graphql.permissions.workspace import WorkspaceBasePermission
from plane.graphql.permissions.project import ProjectBasePermission
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.paginator import paginate
@strawberry.type
class ProjectQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def projects(self, info: Info, slug: str) -> list[ProjectType]:
project = await sync_to_async(list)(
Project.objects.filter(workspace__slug=slug)
.filter(
async def projects(
self,
info: Info,
slug: str,
type: Optional[str] = "all",
cursor: Optional[str] = None,
) -> PaginatorResponse[ProjectType]:
project_query = Project.objects.filter(
workspace__slug=slug, archived_at__isnull=True
)
if type == "created":
project_query = project_query.filter(created_by=info.context.user)
elif type == "joined":
project_query = project_query.filter(
Q(
project_projectmember__member=info.context.user,
project_projectmember__is_active=True,
)
)
.annotate(
project = await sync_to_async(list)(
project_query.annotate(
is_favorite=Exists(
UserFavorite.objects.filter(
user=info.context.user,
@@ -42,8 +57,7 @@ class ProjectQuery:
project_id=OuterRef("pk"),
)
)
)
.annotate(
).annotate(
is_member=Exists(
ProjectMember.objects.filter(
member=info.context.user,
@@ -54,19 +68,19 @@ class ProjectQuery:
)
)
)
return project
return paginate(results_object=project, cursor=cursor)
@strawberry.type
class ProjectMembersQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[ProjectBasePermission()])]
)
async def projectMembers(
self, info: Info, slug: str, project: strawberry.ID
) -> list[ProjectMemberType]:
project = await sync_to_async(list)(
project_members = await sync_to_async(list)(
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project,
@@ -74,4 +88,5 @@ class ProjectMembersQuery:
member__is_bot=False,
)
)
return project
return project_members

View File

@@ -1,20 +1,33 @@
# Third-Party Imports
import strawberry
# Python Standard Library Imports
from asgiref.sync import sync_to_async
from typing import Optional
# Django Imports
from django.db.models import Q
# Strawberry Imports
from strawberry.types import Info
from strawberry.permission import PermissionExtension
# Module Imports
from plane.db.models import Profile
from plane.graphql.types.users import UserType, ProfileType
from plane.graphql.permissions.workspace import IsAuthenticated
from plane.db.models import Profile, UserFavorite, UserRecentVisit
from plane.graphql.types.users import (
UserType,
ProfileType,
UserFavoriteType,
UserRecentVisitType,
)
from plane.graphql.permissions.workspace import (
IsAuthenticated,
WorkspaceBasePermission,
)
@strawberry.type
class UserQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[IsAuthenticated()])]
)
@@ -24,7 +37,6 @@ class UserQuery:
@strawberry.type
class ProfileQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[IsAuthenticated()])]
)
@@ -33,3 +45,68 @@ class ProfileQuery:
user=info.context.user
)
return profile
# user favorite
@strawberry.type
class UserFavoritesQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def userFavorites(
self,
info: Info,
slug: str,
limit: Optional[int] = None,
) -> list[UserFavoriteType]:
favorites = await sync_to_async(list)(
UserFavorite.objects.filter(
user=info.context.user,
workspace__slug=slug,
)
.filter(
Q(parent__isnull=True),
Q(project__isnull=True)
| (
Q(project__isnull=False)
& Q(
project__project_projectmember__member=info.context.user
)
& Q(project__project_projectmember__is_active=True)
),
)
.order_by("-created_at")
)
if limit:
favorites = favorites[:limit]
return favorites
# user recent visits
@strawberry.type
class UserRecentVisitQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def userRecentVisit(
self,
info: Info,
slug: str,
limit: Optional[int] = None,
) -> list[UserRecentVisitType]:
recent_visits = await sync_to_async(list)(
UserRecentVisit.objects.filter(
workspace__slug=slug, user=info.context.user
).order_by("-created_at")
)
if limit:
recent_visits = recent_visits[:limit]
return recent_visits

View File

@@ -5,24 +5,36 @@ from asgiref.sync import sync_to_async
# Strawberry Imports
from strawberry.types import Info
from strawberry.scalars import JSON
from strawberry.permission import PermissionExtension
# Django Imports
from django.db.models import Q
# Module Imports
from plane.graphql.types.workspace import WorkspaceType, WorkspaceMemberType
from plane.db.models import Workspace, WorkspaceMember, Issue
from plane.graphql.types.workspace import (
WorkspaceType,
WorkspaceMemberType,
WorkspaceYourWorkType,
)
from plane.db.models import Workspace, WorkspaceMember, Issue, Project, Page
from plane.graphql.utils.issue_filters import issue_filters
from plane.graphql.types.issue import IssueType
from plane.graphql.types.issue import (
IssuesInformationType,
IssuesInformationObjectType,
IssuesType,
)
from plane.graphql.permissions.workspace import (
WorkspaceBasePermission,
IsAuthenticated,
)
from plane.graphql.types.paginator import PaginatorResponse
from plane.graphql.utils.paginator import paginate
from plane.graphql.utils.issue import issue_information_query_execute
@strawberry.type
class WorkspaceQuery:
@strawberry.field(
extensions=[PermissionExtension(permissions=[IsAuthenticated()])]
)
@@ -33,12 +45,12 @@ class WorkspaceQuery:
workspace_member__is_active=True,
).order_by("-created_at")
)
return workspaces
@strawberry.type
class WorkspaceMembersQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
@@ -56,22 +68,62 @@ class WorkspaceMembersQuery:
return workspace_members
# workspace issues information query
@strawberry.type
class WorkspaceIssuesQuery:
class WorkspaceIssuesInformationQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def workspace_issues(
async def workspaceIssuesInformation(
self,
info: Info,
slug: str,
filters: Optional[JSON] = {},
groupBy: Optional[str] = None,
orderBy: Optional[str] = "-created_at",
) -> IssuesInformationType:
filters = issue_filters(filters, "POST")
(
issue_count,
issue_group_info,
) = await issue_information_query_execute(
user=info.context.user,
slug=slug,
filters=filters,
groupBy=groupBy,
orderBy=orderBy,
)
issue_information = IssuesInformationType(
all=IssuesInformationObjectType(
totalIssues=issue_count, groupInfo=issue_group_info
),
active=None,
backlog=None,
)
return issue_information
# workspace issues query
@strawberry.type
class WorkspaceIssuesQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def workspaceIssues(
self,
info: Info,
slug: str,
filters: Optional[JSON] = {},
orderBy: Optional[str] = "-created_at",
groupBy: Optional[str] = None,
) -> list[IssueType]:
cursor: Optional[str] = None,
) -> PaginatorResponse[IssuesType]:
filters = issue_filters(filters, "POST")
issues = await sync_to_async(list)(
@@ -85,4 +137,65 @@ class WorkspaceIssuesQuery:
.order_by(orderBy, "-created_at")
.filter(**filters)
)
return issues
return paginate(results_object=issues, cursor=cursor)
# workspace your work
@strawberry.type
class YourWorkQuery:
@strawberry.field(
extensions=[
PermissionExtension(permissions=[WorkspaceBasePermission()])
]
)
async def yourWork(
self,
info: Info,
slug: str,
) -> WorkspaceYourWorkType:
# projects
projects = await sync_to_async(list)(
Project.objects.filter(workspace__slug=slug)
.filter(
Q(
project_projectmember__member=info.context.user,
project_projectmember__is_active=True,
)
)
.values_list("id", flat=True)
)
# issues
issues = await sync_to_async(list)(
Issue.objects.filter(workspace__slug=slug)
.filter(
Q(
project__project_projectmember__member=info.context.user,
project__project_projectmember__is_active=True,
state__group__in=["unstarted", "started"],
assignees__in=[info.context.user],
),
)
.values_list("id", flat=True)
)
# pages
pages = await sync_to_async(list)(
Page.objects.filter(workspace__slug=slug)
.filter(
Q(
projects__project_projectmember__member=info.context.user,
projects__project_projectmember__is_active=True,
archived_at__isnull=True,
owned_by=info.context.user,
),
)
.values_list("id", flat=True)
)
your_work = WorkspaceYourWorkType(
projects=len(projects), issues=len(issues), pages=len(pages)
)
return your_work

View File

@@ -5,26 +5,41 @@ from strawberry_django.optimizer import DjangoOptimizerExtension
from .queries.workspace import (
WorkspaceQuery,
WorkspaceMembersQuery,
WorkspaceIssuesInformationQuery,
WorkspaceIssuesQuery,
YourWorkQuery,
)
from .queries.users import UserQuery
from .queries.users import UserQuery, UserFavoritesQuery, UserRecentVisitQuery
from .queries.users import ProfileQuery
from .queries.project import ProjectQuery, ProjectMembersQuery
from .queries.label import LabelQuery, WorkspaceLabelQuery
from .queries.state import StateQuery, WorkspaceStateQuery
from .queries.notification import NotificationQuery
from .queries.issue import (
IssuesInformationQuery,
IssueQuery,
RecentIssuesQuery,
IssueUserPropertyQuery,
IssuePropertiesActivityQuery,
IssueCommentActivityQuery,
SubIssuesQuery,
IssueTypesTypeQuery,
)
from .queries.page import PageQuery, UserPageQuery
from .queries.cycle import (
CycleQuery,
CycleIssuesInformationQuery,
CycleIssueQuery,
)
from .queries.module import (
ModuleQuery,
ModuleIssuesInformationQuery,
ModuleIssueQuery,
)
from .queries.page import PageQuery
from .queries.cycle import CycleQuery, CycleIssueQuery
from .queries.module import ModuleQuery, ModuleIssueQuery
from .queries.search import ProjectSearchQuery
from .queries.attachment import IssueAttachmentQuery
from .queries.link import IssueLinkQuery
from .queries.estimate import EstimatePointQuery
# mutations
from .mutations.workspace import WorkspaceMutation, WorkspaceInviteMutation
@@ -37,12 +52,16 @@ from .mutations.issue import (
IssueMutation,
IssueUserPropertyMutation,
IssueAttachmentMutation,
IssueSubscriptionMutation
)
from .mutations.notification import NotificationMutation
from .mutations.user import ProfileMutation
from .mutations.page import PageFavoriteMutation
from .mutations.cycle import CycleIssueMutation
from .mutations.module import ModuleIssueMutation
from .mutations.cycle import CycleIssueMutation, CycleFavoriteMutation
from .mutations.module import ModuleIssueMutation, ModuleFavoriteMutation
from .mutations.link import IssueLinkMutation
from .mutations.favorite import UserFavoriteMutation
# combined query class for all
@strawberry.type
@@ -71,6 +90,18 @@ class Query(
CycleIssueQuery,
ModuleQuery,
ModuleIssueQuery,
YourWorkQuery,
UserFavoritesQuery,
UserRecentVisitQuery,
IssueLinkQuery,
IssuesInformationQuery,
WorkspaceIssuesInformationQuery,
CycleIssuesInformationQuery,
ModuleIssuesInformationQuery,
SubIssuesQuery,
IssueTypesTypeQuery,
EstimatePointQuery,
UserPageQuery,
):
pass
@@ -91,6 +122,11 @@ class Mutation(
IssueAttachmentMutation,
CycleIssueMutation,
ModuleIssueMutation,
IssueLinkMutation,
IssueSubscriptionMutation,
CycleFavoriteMutation,
ModuleFavoriteMutation,
UserFavoriteMutation,
):
pass

View File

@@ -14,6 +14,7 @@ from asgiref.sync import sync_to_async
# Module Imports
from plane.db.models import Cycle, Issue
from plane.graphql.types.users import UserType
@strawberry_django.type(Cycle)
@@ -23,7 +24,6 @@ class CycleType:
description: Optional[str]
start_date: Optional[date]
end_date: Optional[date]
owned_by: strawberry.ID
view_props: Optional[JSON]
sort_order: Optional[float]
external_source: Optional[str]
@@ -39,10 +39,7 @@ class CycleType:
updated_at: datetime
total_issues: int
completed_issues: int
@strawberry.field
def owned_by(self) -> int:
return self.owned_by_id
owned_by: Optional[UserType]
@strawberry.field
def project(self) -> int:
@@ -67,10 +64,22 @@ class CycleType:
@strawberry.field
async def completed_issues(self, info: Info) -> int:
total_issues = await sync_to_async(
completed_issues = await sync_to_async(
lambda: Issue.issue_objects.filter(
issue_cycle__cycle_id=self.id,
state__group="completed"
issue_cycle__cycle_id=self.id, state__group="completed"
).count()
)()
return total_issues
return completed_issues
@strawberry.field
async def assignees_count(self) -> int:
issue_assignees_count = await sync_to_async(
lambda: Issue.issue_objects.filter(
issue_cycle__cycle_id=self.id,
issue_cycle__issue__assignees__id__isnull=False,
)
.values("issue_cycle__issue__assignees__id")
.distinct()
.count()
)()
return issue_assignees_count

View File

@@ -0,0 +1,29 @@
# Strawberry imports
import strawberry
import strawberry_django
# Module Imports
from plane.db.models import EstimatePoint
@strawberry_django.type(EstimatePoint)
class EstimatePointType:
id: strawberry.ID
estimate: strawberry.ID
key: int
description: str
value: str
workspace: strawberry.ID
project: strawberry.ID
@strawberry.field
def project(self) -> int:
return self.project_id
@strawberry.field
def workspace(self) -> int:
return self.workspace_id
@strawberry.field
def estimate(self) -> int:
return self.estimate_id

View File

@@ -20,11 +20,25 @@ from plane.db.models import (
IssueComment,
CycleIssue,
ModuleIssue,
IssueType,
)
@strawberry.type
class IssuesInformationObjectType:
totalIssues: int
groupInfo: Optional[JSON]
@strawberry.type
class IssuesInformationType:
all: Optional[IssuesInformationObjectType]
active: Optional[IssuesInformationObjectType]
backlog: Optional[IssuesInformationObjectType]
@strawberry_django.type(Issue)
class IssueType:
class IssuesType:
id: strawberry.ID
workspace: strawberry.ID
project: strawberry.ID
@@ -51,9 +65,9 @@ class IssueType:
updated_by: strawberry.ID
created_at: datetime
updated_at: datetime
sequence_id: int
cycle: Optional[strawberry.ID]
modules: Optional[list[strawberry.ID]]
type: Optional[strawberry.ID]
@strawberry.field
def state(self) -> int:
@@ -79,6 +93,10 @@ class IssueType:
def created_by(self) -> int:
return self.created_by_id
@strawberry.field
def type(self) -> int:
return self.type_id
@strawberry.field
async def assignees(self) -> Optional[list[strawberry.ID]]:
assignees = await sync_to_async(list)(self.assignees.all())
@@ -227,3 +245,20 @@ class IssueLiteType:
# @strawberry.field
# def project(self) -> int:
# return self.project_id
@strawberry_django.type(IssueType)
class IssueTypesType:
id: strawberry.ID
workspace: strawberry.ID
name: str
description: str
logo_props: JSON
is_default: bool
level: int
is_active: bool
@strawberry.field
def workspace(self) -> int:
return self.workspace_id

View File

@@ -0,0 +1,45 @@
# python imports
from datetime import datetime
# Strawberry imports
import strawberry
import strawberry_django
from strawberry.scalars import JSON
# Module Imports
from plane.db.models import IssueLink
@strawberry_django.type(IssueLink)
class IssueLinkType:
id: strawberry.ID
created_at: datetime
updated_at: datetime
title: str
url: str
metadata: JSON
created_by: strawberry.ID
updated_by: strawberry.ID
project: strawberry.ID
workspace: strawberry.ID
issue: strawberry.ID
@strawberry.field
def workspace(self) -> int:
return self.workspace_id
@strawberry.field
def project(self) -> int:
return self.project_id
@strawberry.field
def issue(self) -> int:
return self.issue_id
@strawberry.field
def created_by(self) -> int:
return self.created_by_id
@strawberry.field
def updated_by(self) -> int:
return self.updated_by_id

View File

@@ -10,10 +10,12 @@ from strawberry.scalars import JSON
# Module Imports
from plane.db.models import Module, Issue
from plane.graphql.types.users import UserType
# Third-party library imports
from asgiref.sync import sync_to_async
@strawberry_django.type(Module)
class ModuleType:
name: str
@@ -30,7 +32,6 @@ class ModuleType:
description_html: Optional[str]
start_date: Optional[date]
target_date: Optional[date]
lead: Optional[strawberry.ID]
members: Optional[list[strawberry.ID]]
view_props: Optional[JSON]
sort_order: float
@@ -40,6 +41,7 @@ class ModuleType:
logo_props: Optional[JSON]
total_issues: int
completed_issues: int
lead: Optional[UserType]
@strawberry.field
def project(self) -> int:
@@ -70,3 +72,16 @@ class ModuleType:
).count()
)()
return total_issues
@strawberry.field
async def assignees_count(self) -> int:
issue_assignees_count = await sync_to_async(
lambda: Issue.issue_objects.filter(
issue_module__module_id=self.id,
issue_module__issue__assignees__id__isnull=False,
)
.values("issue_module__issue__assignees__id")
.distinct()
.count()
)()
return issue_assignees_count

View File

@@ -0,0 +1,27 @@
# Python imports
from typing import TypeVar, Optional, Generic
# Django imports
from django.db.models import Model
# Strawberry imports
import strawberry
# Defining a generic type variable
T = TypeVar("T", bound=Model)
@strawberry.type
class PaginatorInfo:
prev_cursor: Optional[str]
cursor: str
next_cursor: Optional[str]
prev_page_results: bool
next_page_results: bool
count: int
total_count: int
@strawberry.type
class PaginatorResponse(PaginatorInfo, Generic[T]):
results: list[T]

View File

@@ -43,6 +43,7 @@ class ProjectType:
is_favorite: bool
total_members: int
total_issues: int
total_active_issues: int
@strawberry.field
def workspace(self) -> int:
@@ -80,6 +81,15 @@ class ProjectType:
)()
return projects
@strawberry.field
async def total_active_issues(self, info: Info) -> int:
project_active_issues = await sync_to_async(
lambda: Issue.issue_objects.filter(project_id=self.id)
.filter(state__group__in=["unstarted", "started"])
.count()
)()
return project_active_issues
@strawberry_django.type(ProjectMember)
class ProjectMemberType:

View File

@@ -10,7 +10,19 @@ from strawberry.types import Info
from strawberry.scalars import JSON
# Module imports
from plane.db.models import User, Profile, Workspace
from plane.db.models import (
User,
Profile,
Workspace,
UserFavorite,
UserRecentVisit,
Project,
Cycle,
Module,
Issue,
IssueView,
Page,
)
@strawberry_django.type(User)
@@ -83,3 +95,201 @@ class ProfileType:
return fallback_workspace.id
return None
# user favorite
@strawberry.type
class UserFavoriteEntityData:
id: Optional[strawberry.ID]
name: Optional[str]
logo_props: Optional[JSON]
@strawberry_django.type(UserFavorite)
class UserFavoriteType:
id: strawberry.ID
entity_type: str
entity_identifier: str
name: Optional[str]
is_folder: bool
sequence: float
parent: Optional[strawberry.ID]
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime]
project: Optional[strawberry.ID]
@strawberry.field
def project(self) -> int:
return self.project_id
@strawberry.field
async def entity_data(self) -> Optional[UserFavoriteEntityData]:
# where entity_identifier is project_id and entity_type is project
if self.entity_identifier and self.entity_type == "project":
project = await sync_to_async(
Project.objects.filter(id=self.entity_identifier).first
)()
if project:
return UserFavoriteEntityData(
id=project.id,
name=project.name,
logo_props=project.logo_props,
)
return None
# where entity_identifier is cycle_id and entity_type is cycle
elif self.entity_identifier and self.entity_type == "cycle":
cycle = await sync_to_async(
Cycle.objects.filter(id=self.entity_identifier).first
)()
if cycle:
return UserFavoriteEntityData(
id=cycle.id,
name=cycle.name,
logo_props=cycle.logo_props,
)
return None
# where entity_identifier is module id and entity_type is module
elif self.entity_identifier and self.entity_type == "module":
module = await sync_to_async(
Module.objects.filter(id=self.entity_identifier).first
)()
if module:
return UserFavoriteEntityData(
id=module.id,
name=module.name,
logo_props=module.logo_props,
)
return None
# where entity_identifier is issue id and entity_type is issue
elif self.entity_identifier and self.entity_type == "issue":
issue = await sync_to_async(
Issue.objects.filter(id=self.entity_identifier).first
)()
if issue:
return UserFavoriteEntityData(
id=issue.id, name=issue.name, logo_props=None
)
return None
# where entity_identifier is issue_view id and entity_type is issue_view
elif self.entity_identifier and self.entity_type == "view":
issue_view = await sync_to_async(
IssueView.objects.filter(id=self.entity_identifier).first
)()
if issue_view:
return UserFavoriteEntityData(
id=issue_view.id,
name=issue_view.name,
logo_props=issue_view.logo_props,
)
return None
# where entity_identifier is page id and entity_type is page
elif self.entity_identifier and self.entity_type == "page":
page = await sync_to_async(
Page.objects.filter(id=self.entity_identifier).first
)()
if page:
return UserFavoriteEntityData(
id=page.id,
name=page.name,
logo_props=page.logo_props,
)
return None
# where entity_identifier and entity_type is None
return None
# user recent visit
@strawberry_django.type(UserRecentVisit)
class UserRecentVisitType:
id: strawberry.ID
entity_identifier: str
entity_name: str
user: strawberry.ID
created_at: datetime
updated_at: datetime
deleted_at: Optional[datetime]
project: Optional[strawberry.ID]
@strawberry.field
def project(self) -> int:
return self.project_id
@strawberry.field
def user(self) -> int:
return self.user_id
@strawberry.field
async def entity_data(self) -> Optional[UserFavoriteEntityData]:
# where entity_identifier is project_id and entity_name is project
if self.entity_identifier and self.entity_name == "project":
project = await sync_to_async(
Project.objects.filter(id=self.entity_identifier).first
)()
if project:
return UserFavoriteEntityData(
id=project.id,
name=project.name,
logo_props=project.logo_props,
)
return None
# where entity_identifier is cycle_id and entity_name is cycle
elif self.entity_identifier and self.entity_name == "cycle":
cycle = await sync_to_async(
Cycle.objects.filter(id=self.entity_identifier).first
)()
if cycle:
return UserFavoriteEntityData(
id=cycle.id,
name=cycle.name,
logo_props=cycle.logo_props,
)
return None
# where entity_identifier is module id and entity_name is module
elif self.entity_identifier and self.entity_name == "module":
module = await sync_to_async(
Module.objects.filter(id=self.entity_identifier).first
)()
if module:
return UserFavoriteEntityData(
id=module.id,
name=module.name,
logo_props=module.logo_props,
)
return None
# where entity_identifier is issue id and entity_name is issue
elif self.entity_identifier and self.entity_name == "issue":
issue = await sync_to_async(
Issue.objects.filter(id=self.entity_identifier).first
)()
if issue:
return UserFavoriteEntityData(
id=issue.id, name=issue.name, logo_props=None
)
return None
# where entity_identifier is issue_view id and entity_name is issue_view
elif self.entity_identifier and self.entity_name == "view":
issue_view = await sync_to_async(
IssueView.objects.filter(id=self.entity_identifier).first
)()
if issue_view:
return UserFavoriteEntityData(
id=issue_view.id,
name=issue_view.name,
logo_props=issue_view.logo_props,
)
return None
# where entity_identifier is page id and entity_name is page
elif self.entity_identifier and self.entity_name == "page":
page = await sync_to_async(
Page.objects.filter(id=self.entity_identifier).first
)()
if page:
return UserFavoriteEntityData(
id=page.id,
name=page.name,
logo_props=page.logo_props,
)
return None
# where entity_identifier and entity_name is None
return None

View File

@@ -33,6 +33,10 @@ class WorkspaceMemberType:
role: int
is_active: bool
async def member(self) -> UserType:
member = await sync_to_async(self.member)()
return member
# workspace your work
@strawberry.type
class WorkspaceYourWorkType:
projects: int
issues: int
pages: int

View File

@@ -0,0 +1,90 @@
# Third-Party Imports
import strawberry
from enum import Enum
# Python Standard Library Imports
from asgiref.sync import sync_to_async
from typing import Optional
# Strawberry Imports
from strawberry.scalars import JSON
# Django Imports
from django.db.models import Count, F
# Module Imports
from plane.db.models import Issue
# Enum for grouping issues
class IssuesGroupBy(Enum):
PRIORITY = "priority"
LABELS = "labels__id"
STATE = "state__id"
STATE_GROUP = "state__group"
ASSIGNEES = "assignees__id"
# Function to execute the issue information query
async def issue_information_query_execute(
user: strawberry.ID,
slug: str,
project: Optional[strawberry.ID] = None,
cycle: Optional[strawberry.ID] = None,
module: Optional[strawberry.ID] = None,
filters: Optional[JSON] = {},
groupBy: Optional[str] = None,
orderBy: Optional[str] = "-created_at",
):
# Initialize variables
order_by_group = None
total_issues_count = 0
group_by_info = None
# Check if groupBy is not None
if groupBy is not None:
if groupBy == "priority":
order_by_group = IssuesGroupBy.PRIORITY.value
elif groupBy == "labels":
order_by_group = IssuesGroupBy.LABELS.value
elif groupBy == "state":
order_by_group = IssuesGroupBy.STATE.value
elif groupBy == "state_group":
order_by_group = IssuesGroupBy.STATE_GROUP.value
elif groupBy == "assignees":
order_by_group = IssuesGroupBy.ASSIGNEES.value
# Query the issues
issue_query = Issue.objects.filter(workspace__slug=slug)
# Filter the issues based on the project, cycle, and module
if project is not None:
issue_query = Issue.objects.filter(project_id=project)
if cycle is not None:
issue_query = issue_query.filter(issue_cycle__cycle_id=cycle)
if module is not None:
issue_query = issue_query.filter(issue_module__module_id=module)
issue_query = (
issue_query.filter(
project__project_projectmember__member=user,
project__project_projectmember__is_active=True,
)
.filter(**filters)
.order_by(orderBy, "-created_at")
)
# Get the count and group by information
total_issues_count = await sync_to_async(issue_query.count)()
if order_by_group is not None:
group_by_info = await sync_to_async(list)(
issue_query.values(order_by_group)
.annotate(total_issues=Count(order_by_group))
.order_by(F(order_by_group).asc(nulls_last=True))
.values(groupKey=F(order_by_group), totalIssues=F("total_issues"))
)
group_by_info = [
item for item in group_by_info if item["groupKey"] is not None
]
return total_issues_count, group_by_info

View File

@@ -0,0 +1,79 @@
# Python imports
from typing import Optional
# Module imports
from plane.graphql.types.paginator import PaginatorResponse
# Constants
PAGINATOR_MAX_LIMIT = 100
class Cursor:
def __init__(
self, page_size=PAGINATOR_MAX_LIMIT, current_page=0, offset=0
):
self.page_size = page_size
self.current_page = current_page
self.offset = offset
def __str__(self):
return f"{self.page_size}:{self.current_page}:{self.offset}"
@classmethod
def from_string(self, cursor):
cursor_bits = cursor.split(":")
if len(cursor_bits) != 3:
return ValueError("Invalid cursor format")
return self(
int(cursor_bits[0]), int(cursor_bits[1]), int(cursor_bits[2])
)
def paginate(
results_object,
cursor: Optional[str] = None,
):
"""
Paginator Information Results
"""
cursor_object = Cursor.from_string(cursor)
if cursor_object is None:
cursor_object = Cursor(0, 0, 0)
total_results = len(results_object)
page_size = min(cursor_object.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 = results_object[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
return PaginatorResponse(
prev_cursor=prev_cursor,
cursor=cursor,
next_cursor=next_cursor,
prev_page_results=prev_page_results,
next_page_results=next_page_results,
count=len(paginated_data),
total_count=total_results,
results=paginated_data,
)

View File

@@ -126,8 +126,8 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);

View File

@@ -6,7 +6,7 @@ import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands,
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
getHTML: () => string;
clearEditor: () => void;
clearEditor: (emitUpdate?: boolean) => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
};

View File

@@ -50,6 +50,22 @@ 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;
}
export type TPageEmbedResponse = {
id: string;
name: string;

View File

@@ -35,6 +35,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
tabIndex,
closeOnSelect,
openOnHover = false,
useCaptureForOutsideClick = false,
} = props;
const [referenceElement, setReferenceElement] = React.useState<HTMLButtonElement | null>(null);
@@ -88,7 +89,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
}
};
useOutsideClickDetector(dropdownRef, closeDropdown);
useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick);
let menuItems = (
<Menu.Items className={cn("fixed z-10", menuItemsClassName)} static>

View File

@@ -17,6 +17,7 @@ export interface IDropdownProps {
optionsClassName?: string;
placement?: Placement;
tabIndex?: number;
useCaptureForOutsideClick?: boolean;
}
export interface ICustomMenuDropdownProps extends IDropdownProps {

View File

@@ -1,7 +1,7 @@
import React, { useEffect } from "react";
// TODO: move it to helpers package
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void, useCapture = false) => {
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);
document.addEventListener("mousedown", handleClick, useCapture);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("mousedown", handleClick, useCapture);
};
});
};

View File

@@ -20,7 +20,7 @@ type Props = {
export const FilterState: React.FC<Props> = observer((props) => {
const { appliedFilters, handleUpdate, searchQuery, allowedValues } = props;
//store
const { getStateById, states: storeStates } = useStates();
const { sortedStates: storeStates } = useStates();
const [itemsToRender, setItemsToRender] = useState(5);
const [previewEnabled, setPreviewEnabled] = useState(true);
@@ -29,7 +29,7 @@ export const FilterState: React.FC<Props> = observer((props) => {
const states =
allowedValues && allowedValues.length > 0
? allowedValues.map((stateId: string) => getStateById(stateId))
? storeStates?.filter((state) => allowedValues.includes(state.id))
: storeStates;
const sortedOptions = useMemo(() => {

View File

@@ -26,7 +26,7 @@ export default function WorkspacePagesLayout({ children }: { children: React.Rea
>
<>
<PagesAppCommandPalette />
<div className="relative flex h-screen w-full overflow-hidden">
<div className="relative flex h-full w-full overflow-hidden">
<PagesAppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}

View File

@@ -71,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 fixed right-0 top-0 z-10"
"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-50"
)}
style={{
boxShadow:

View File

@@ -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 fixed right-0 top-0 z-10"
"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-50"
)}
style={{
boxShadow:

View File

@@ -64,7 +64,7 @@ const PageDetailsPage = observer(() => {
<>
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<div className="relative h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
<IssuePeekOverview />
</div>

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { FileText } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
@@ -25,6 +25,7 @@ export interface IPagesHeaderProps {
export const PageDetailsHeader = observer(() => {
// router
const { workspaceSlug, pageId } = useParams();
const searchParams = useSearchParams();
// state
const [isOpen, setIsOpen] = useState(false);
// store hooks
@@ -55,6 +56,8 @@ export const PageDetailsHeader = observer(() => {
}
};
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">
@@ -157,7 +160,7 @@ export const PageDetailsHeader = observer(() => {
</div>
</div>
<PageDetailsHeaderExtraActions />
{isContentEditable && (
{isContentEditable && !isVersionHistoryOverlayActive && (
<Button
variant="primary"
size="sm"

View File

@@ -1,4 +1,4 @@
import { Metadata } from "next";
import { Metadata, Viewport } from "next";
import Script from "next/script";
// styles
import "@/styles/globals.css";
@@ -30,6 +30,15 @@ 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");
@@ -53,10 +62,6 @@ 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" />
@@ -77,7 +82,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* free trial banner */}
<FreeTrialBanner />
</div>
<div className="w-full h-full overflow-hidden">{children}</div>
<div className="w-full h-full overflow-hidden relative">{children}</div>
</div>
</AppProvider>
</body>

View File

@@ -15,19 +15,33 @@ type TProPiceFrequency = "month" | "year";
type TProPlanPrice = {
key: string;
price: string;
currency: string;
price: number;
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", price: "$7", recurring: "month" },
{ key: "yearly", price: "$5", recurring: "year" },
{ key: "monthly", currency: "$", price: 8, recurring: "month" },
{ key: "yearly", currency: "$", price: 6, 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";
@@ -55,7 +69,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">
-28%
-{yearlyDiscount}%
</span>
)}
</>
@@ -69,8 +83,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.recurring === "month" && "$7"}
{price.recurring === "year" && "$5"}
{price.currency}
{price.price}
</div>
<div className="text-sm text-custom-text-300">a user per month</div>
</div>

View File

@@ -10,6 +10,7 @@ type Props = {
as?: keyof JSX.IntrinsicElements;
classNames?: string;
placeholderChildren?: ReactNode;
defaultValue?: boolean;
};
const RenderIfVisible: React.FC<Props> = (props) => {
@@ -20,10 +21,11 @@ const RenderIfVisible: React.FC<Props> = (props) => {
horizontalOffset = 0,
as = "div",
children,
defaultValue = false,
classNames = "",
placeholderChildren = null, //placeholder children
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>();
const [shouldVisible, setShouldVisible] = useState<boolean>(defaultValue);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);

View File

@@ -46,7 +46,7 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose, isArchived, isPeekMode = false } = props;
const { cycleId, handleClose, isArchived } = props;
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
@@ -262,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 ${isPeekMode ? "pt-5" : "pt-20"}`}
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
>
<div>
<button

View File

@@ -85,8 +85,7 @@ 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) ||
inboxIssue?.created_by === currentUser?.id;
(!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) || issue?.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);

View File

@@ -1,8 +1,9 @@
import { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { FileRejection, 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";
@@ -36,24 +37,46 @@ export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((p
const issueAttachments = getAttachmentsByIssueId(issueId);
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
(acceptedFiles: File[], rejectedFiles:FileRejection[] ) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
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,
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.",
})
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => setIsLoading(false));
.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;
},
[handleAttachmentOperations, workspaceSlug]
);

View File

@@ -1,8 +1,9 @@
"use client";
import React, { FC, useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
import { FileRejection, 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
@@ -33,31 +34,54 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
// handlers
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
(acceptedFiles: File[], rejectedFiles:FileRejection[] ) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
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,
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.",
})
})
);
setIsLoading(true);
handleAttachmentOperations.create(formData).finally(() => {
setLastWidgetAction("attachments");
setIsLoading(false);
.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,
@@ -71,4 +95,4 @@ export const IssueAttachmentActionButton: FC<Props> = observer((props) => {
{customButton ? customButton : <Plus className="h-4 w-4" />}
</button>
);
});
});

View File

@@ -7,9 +7,10 @@ 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 { useWorkspace } from "@/hooks/store";
import { useIssueDetail, useWorkspace } from "@/hooks/store";
// editor
import { TActivityOperations } from "../root";
@@ -27,6 +28,7 @@ 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
@@ -58,6 +60,9 @@ 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);

View File

@@ -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 } from "../utils";
import { HIGHLIGHT_CLASS, getIssueBlockId, isIssueNew } from "../utils";
import { TRenderQuickActions } from "./list-view-types";
type Props = {
@@ -36,6 +36,7 @@ type Props = {
canDropOverIssue: boolean;
isParentIssueBeingDragged?: boolean;
isLastChild?: boolean;
shouldRenderByDefault?: boolean;
};
export const IssueBlockRoot: FC<Props> = observer((props) => {
@@ -56,6 +57,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
isParentIssueBeingDragged = false,
isLastChild = false,
selectionHelpers,
shouldRenderByDefault,
} = props;
// states
const [isExpanded, setExpanded] = useState<boolean>(false);
@@ -114,7 +116,7 @@ export const IssueBlockRoot: FC<Props> = observer((props) => {
issueBlockRef?.current?.classList?.remove(HIGHLIGHT_CLASS);
});
if (!issueId) return null;
if (!issueId || !issuesMap[issueId]?.created_at) return null;
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
return (
@@ -126,6 +128,7 @@ 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}
@@ -165,6 +168,7 @@ 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"} />}

View File

@@ -166,6 +166,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -125,6 +125,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -187,6 +187,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -115,6 +115,7 @@ export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
placement={placements}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -183,6 +183,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -177,6 +177,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
portalElement={portalElement}
menuItemsClassName="z-[14]"
maxHeight="lg"
useCaptureForOutsideClick
closeOnSelect
>
{MENU_ITEMS.map((item) => {

View File

@@ -606,4 +606,16 @@ 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;
};

View File

@@ -68,7 +68,7 @@ type Props = {
// TODO: refactor this component
export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { moduleId, handleClose, isArchived, isPeekMode = false } = props;
const { moduleId, handleClose, isArchived } = props;
// states
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
@@ -311,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 ${isPeekMode ? "pt-5" : "pt-20"}`}
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
>
<div>
<button

View File

@@ -1,8 +1,8 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, Link, Lock, LockOpen } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
@@ -11,6 +11,7 @@ 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";
@@ -23,6 +24,8 @@ 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,
@@ -40,6 +43,8 @@ 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(() =>
@@ -145,6 +150,19 @@ 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 (

View File

@@ -1,12 +1,23 @@
import { useRef, useState } from "react";
import { useEffect, 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";
import { PageEditorHeaderRoot, PageEditorBody } from "@/components/pages";
// components
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay } from "@/components/pages";
// hooks
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 = {
@@ -16,34 +27,40 @@ 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 } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false);
// project-description
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription(
{
editorRef,
page,
projectId,
workspaceSlug,
}
);
const {
handleDescriptionChange,
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
} = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// update query params
const { updateQueryParams } = useQueryParams();
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
@@ -65,8 +82,48 @@ 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}
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 ?? ""}
/>
<PageEditorHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}

View File

@@ -4,5 +4,6 @@ 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";

View File

@@ -0,0 +1,111 @@
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;
versionDetails: TPageVersion | undefined;
};
export const PagesVersionEditor: React.FC<Props> = observer((props) => {
const { activeVersion, isCurrentVersionActive, versionDetails } = props;
// params
const { workspaceSlug, projectId, pageId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
const { description_html } = usePage(pageId.toString() ?? "");
// 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 ? 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,
},
}}
/>
);
});

View File

@@ -0,0 +1,6 @@
export * from "./editor";
export * from "./main-content";
export * from "./root";
export * from "./sidebar-list-item";
export * from "./sidebar-list";
export * from "./sidebar-root";

View File

@@ -0,0 +1,114 @@
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";
// components
import { PagesVersionEditor } from "@/components/pages";
// helpers
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
type Props = {
activeVersion: string | null;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleClose: () => void;
handleRestore: (descriptionHTML: string) => Promise<void>;
pageId: string;
};
export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
const { activeVersion, fetchVersionDetails, handleClose, handleRestore, pageId } = 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 () => {
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);
};
return (
<div className="flex-grow flex flex-col">
{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 && (
<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">
<PagesVersionEditor
activeVersion={activeVersion}
isCurrentVersionActive={isCurrentVersionActive}
versionDetails={versionDetails}
/>
</div>
</>
)}
</div>
);
});

View File

@@ -0,0 +1,50 @@
// 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;
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;
};
export const PageVersionsOverlay: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, fetchVersionDetails, handleRestore, isOpen, onClose, pageId } = 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}
fetchVersionDetails={fetchVersionDetails}
handleClose={handleClose}
handleRestore={handleRestore}
pageId={pageId}
/>
<PageVersionsSidebarRoot
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
handleClose={handleClose}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
};

View File

@@ -0,0 +1,48 @@
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>
);
});

View File

@@ -0,0 +1,99 @@
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>
);
};

View File

@@ -0,0 +1,38 @@
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>
);
};

View File

@@ -21,11 +21,9 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
// form data
const {
handleSubmit,
watch,
control,
setValue,
reset,
formState: { isSubmitting, isDirty, dirtyFields },
formState: { isSubmitting, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
@@ -93,9 +91,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
render={({ field: { value, onChange } }) => (
<Checkbox
checked={value}
indeterminate={!value && watch("issue_completed")}
onChange={() => {
setValue("issue_completed", !value, { shouldDirty: true });
onChange(!value);
}}
containerClassName="mx-2"
@@ -155,7 +151,7 @@ export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) =>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>

View File

@@ -3,6 +3,8 @@
import { useCallback, useEffect, useMemo } from "react";
// hooks
import { useMultipleSelectStore } from "@/hooks/store";
//
import useReloadConfirmations from "./use-reload-confirmation";
export type TEntityDetails = {
entityID: string;
@@ -52,6 +54,15 @@ 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(

View File

@@ -1,20 +1,19 @@
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();
@@ -183,6 +182,19 @@ 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 {
@@ -190,5 +202,6 @@ export const usePageDescription = (props: Props) => {
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
};
};

View File

@@ -0,0 +1,39 @@
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,
};
};

View File

@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react";
//TODO: remove temp flag isActive later and use showAlert as the source of truth
const useReloadConfirmations = (isActive = true) => {
const [showAlert, setShowAlert] = useState(false);
const useReloadConfirmations = (isActive = true, message?: string, defaultShowAlert = false, onLeave?: () => void) => {
const [showAlert, setShowAlert] = useState(defaultShowAlert);
const alertMessage = message ?? "Are you sure you want to leave? Changes you made may not be saved.";
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
@@ -28,8 +30,10 @@ const useReloadConfirmations = (isActive = true) => {
const isAnchorTargetBlank = anchorElement.getAttribute("target") === "_blank";
if (isAnchorTargetBlank) return;
// show confirm dialog
const leave = confirm("Are you sure you want to leave? Changes you made may not be saved.");
if (!leave) {
const isLeaving = confirm(alertMessage);
if (isLeaving) {
onLeave && onLeave();
} else {
event.preventDefault();
event.stopPropagation();
}

View File

@@ -1 +1,2 @@
export * from "./project-page-version.service";
export * from "./project-page.service";

View File

@@ -0,0 +1,33 @@
// plane types
import { TPageVersion } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class ProjectPageVersionService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchAllVersions(workspaceSlug: string, projectId: string, pageId: string): Promise<TPageVersion[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchVersionById(
workspaceSlug: string,
projectId: string,
pageId: string,
versionId: string
): Promise<TPageVersion> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/${versionId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -71,6 +71,7 @@ export const EstimatePointSwitchRoot: FC<TEstimatePointSwitchRoot> = observer((p
handleEstimatePointError(prevValue[index].key, "", "", undefined, "delete");
return prevValue;
});
setSwitchLoader(false);
};
const isValidEstimatePoints = (estimateSystemSwitchType: TEstimateSystemKeys) => {
@@ -168,6 +169,8 @@ export const EstimatePointSwitchRoot: FC<TEstimatePointSwitchRoot> = observer((p
});
handleClose();
setSwitchLoader(false);
} else {
setSwitchLoader(false);
}
} catch (error) {
setToast({

View File

@@ -54,8 +54,9 @@ export const FreeTrialBanner: FC = observer(() => {
rel="noopener noreferrer"
className="underline underline-offset-4 hover:font-bold transition-all"
>
Pro features.
Pro features
</a>
.
</div>
<div className="flex-shrink-0">
<Button variant="outline-primary" size="sm" onClick={() => setPricingModalOpen(true)}>

View File

@@ -101,6 +101,7 @@ const Attributes: React.FC<Props> = observer((props) => {
tabIndex={5}
buttonClassName="z-1 px-2 py-0 h-5"
className="h-5 my-auto"
projectId={project.id}
disabled={!isEditingAllowed || isArchived}
showTooltip
button={