mirror of
https://github.com/makeplane/plane.git
synced 2026-09-03 12:50:29 +02:00
29
admin/core/components/authentication/auth-banner.tsx
Normal file
29
admin/core/components/authentication/auth-banner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./auth-banner";
|
||||
export * from "./email-config-switch";
|
||||
export * from "./password-config-switch";
|
||||
export * from "./authentication-method-card";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -92,6 +92,7 @@ from .page import (
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
PageVersionSerializer,
|
||||
PageVersionDetailSerializer,
|
||||
)
|
||||
|
||||
from .estimate import (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
61
apiserver/plane/graphql/mutations/favorite.py
Normal file
61
apiserver/plane/graphql/mutations/favorite.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
67
apiserver/plane/graphql/mutations/link.py
Normal file
67
apiserver/plane/graphql/mutations/link.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
28
apiserver/plane/graphql/queries/estimate.py
Normal file
28
apiserver/plane/graphql/queries/estimate.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
35
apiserver/plane/graphql/queries/link.py
Normal file
35
apiserver/plane/graphql/queries/link.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
0
apiserver/plane/graphql/queries/relation.py
Normal file
0
apiserver/plane/graphql/queries/relation.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
29
apiserver/plane/graphql/types/estimate.py
Normal file
29
apiserver/plane/graphql/types/estimate.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
45
apiserver/plane/graphql/types/link.py
Normal file
45
apiserver/plane/graphql/types/link.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
27
apiserver/plane/graphql/types/paginator.py
Normal file
27
apiserver/plane/graphql/types/paginator.py
Normal 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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
90
apiserver/plane/graphql/utils/issue.py
Normal file
90
apiserver/plane/graphql/utils/issue.py
Normal 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
|
||||
79
apiserver/plane/graphql/utils/paginator.py
Normal file
79
apiserver/plane/graphql/utils/paginator.py
Normal 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,
|
||||
)
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
16
packages/types/src/pages.d.ts
vendored
16
packages/types/src/pages.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface IDropdownProps {
|
||||
optionsClassName?: string;
|
||||
placement?: Placement;
|
||||
tabIndex?: number;
|
||||
useCaptureForOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
// TODO: move it to helpers package
|
||||
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
|
||||
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);
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"} />}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -125,6 +125,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = observer((
|
||||
placement={placements}
|
||||
menuItemsClassName="z-[14]"
|
||||
maxHeight="lg"
|
||||
useCaptureForOutsideClick
|
||||
closeOnSelect
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
111
web/core/components/pages/version/editor.tsx
Normal file
111
web/core/components/pages/version/editor.tsx
Normal 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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
6
web/core/components/pages/version/index.ts
Normal file
6
web/core/components/pages/version/index.ts
Normal 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";
|
||||
114
web/core/components/pages/version/main-content.tsx
Normal file
114
web/core/components/pages/version/main-content.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
50
web/core/components/pages/version/root.tsx
Normal file
50
web/core/components/pages/version/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
web/core/components/pages/version/sidebar-list-item.tsx
Normal file
48
web/core/components/pages/version/sidebar-list-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
99
web/core/components/pages/version/sidebar-list.tsx
Normal file
99
web/core/components/pages/version/sidebar-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
web/core/components/pages/version/sidebar-root.tsx
Normal file
38
web/core/components/pages/version/sidebar-root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
39
web/core/hooks/use-query-params.ts
Normal file
39
web/core/hooks/use-query-params.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./project-page-version.service";
|
||||
export * from "./project-page.service";
|
||||
|
||||
33
web/core/services/page/project-page-version.service.ts
Normal file
33
web/core/services/page/project-page-version.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user