diff --git a/admin/core/components/authentication/auth-banner.tsx b/admin/core/components/authentication/auth-banner.tsx new file mode 100644 index 0000000000..191d7a0a77 --- /dev/null +++ b/admin/core/components/authentication/auth-banner.tsx @@ -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 = (props) => { + const { bannerData, handleBannerData } = props; + + if (!bannerData) return <>; + return ( +
+
+ +
+
{bannerData?.message}
+
handleBannerData && handleBannerData(undefined)} + > + +
+
+ ); +}; diff --git a/admin/core/components/authentication/index.ts b/admin/core/components/authentication/index.ts index 2c13b77289..d189a727ba 100644 --- a/admin/core/components/authentication/index.ts +++ b/admin/core/components/authentication/index.ts @@ -1,3 +1,4 @@ +export * from "./auth-banner"; export * from "./email-config-switch"; export * from "./password-config-switch"; export * from "./authentication-method-card"; diff --git a/admin/core/components/login/sign-in-form.tsx b/admin/core/components/login/sign-in-form.tsx index 30d7011e7f..aa67d4936e 100644 --- a/admin/core/components/login/sign-in-form.tsx +++ b/admin/core/components/login/sign-in-form.tsx @@ -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(undefined); const [formData, setFormData] = useState(defaultFromData); const [isSubmitting, setIsSubmitting] = useState(false); + const [errorInfo, setErrorInfo] = useState(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 (
@@ -103,7 +121,11 @@ export const InstanceSignInForm: FC = (props) => {

- {errorData.type && errorData?.message && } + {errorData.type && errorData?.message ? ( + + ) : ( + <>{errorInfo && setErrorInfo(value)} />} + )}
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 diff --git a/apiserver/plane/graphql/mutations/favorite.py b/apiserver/plane/graphql/mutations/favorite.py new file mode 100644 index 0000000000..f5b5ad2044 --- /dev/null +++ b/apiserver/plane/graphql/mutations/favorite.py @@ -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 diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index 5cfe5b18bb..74803265ef 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -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 diff --git a/apiserver/plane/graphql/mutations/link.py b/apiserver/plane/graphql/mutations/link.py new file mode 100644 index 0000000000..62669132b4 --- /dev/null +++ b/apiserver/plane/graphql/mutations/link.py @@ -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 diff --git a/apiserver/plane/graphql/mutations/module.py b/apiserver/plane/graphql/mutations/module.py index 6548be37f7..bb05373761 100644 --- a/apiserver/plane/graphql/mutations/module.py +++ b/apiserver/plane/graphql/mutations/module.py @@ -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 diff --git a/apiserver/plane/graphql/queries/cycle.py b/apiserver/plane/graphql/queries/cycle.py index 6c806eaf0f..a171778181 100644 --- a/apiserver/plane/graphql/queries/cycle.py +++ b/apiserver/plane/graphql/queries/cycle.py @@ -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) diff --git a/apiserver/plane/graphql/queries/estimate.py b/apiserver/plane/graphql/queries/estimate.py new file mode 100644 index 0000000000..adc704d036 --- /dev/null +++ b/apiserver/plane/graphql/queries/estimate.py @@ -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 diff --git a/apiserver/plane/graphql/queries/issue.py b/apiserver/plane/graphql/queries/issue.py index 1b0eb18794..8ead450bfa 100644 --- a/apiserver/plane/graphql/queries/issue.py +++ b/apiserver/plane/graphql/queries/issue.py @@ -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 diff --git a/apiserver/plane/graphql/queries/link.py b/apiserver/plane/graphql/queries/link.py new file mode 100644 index 0000000000..62e975cecf --- /dev/null +++ b/apiserver/plane/graphql/queries/link.py @@ -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 diff --git a/apiserver/plane/graphql/queries/module.py b/apiserver/plane/graphql/queries/module.py index 7b68a12546..c3c5b13de0 100644 --- a/apiserver/plane/graphql/queries/module.py +++ b/apiserver/plane/graphql/queries/module.py @@ -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) diff --git a/apiserver/plane/graphql/queries/page.py b/apiserver/plane/graphql/queries/page.py index d2c4180a2d..e9a7f6c868 100644 --- a/apiserver/plane/graphql/queries/page.py +++ b/apiserver/plane/graphql/queries/page.py @@ -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, diff --git a/apiserver/plane/graphql/queries/project.py b/apiserver/plane/graphql/queries/project.py index 2d86f4f3f5..a141c1b2ca 100644 --- a/apiserver/plane/graphql/queries/project.py +++ b/apiserver/plane/graphql/queries/project.py @@ -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 diff --git a/apiserver/plane/graphql/queries/relation.py b/apiserver/plane/graphql/queries/relation.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/graphql/queries/users.py b/apiserver/plane/graphql/queries/users.py index 8189f78f39..07f72c18ac 100644 --- a/apiserver/plane/graphql/queries/users.py +++ b/apiserver/plane/graphql/queries/users.py @@ -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 diff --git a/apiserver/plane/graphql/queries/workspace.py b/apiserver/plane/graphql/queries/workspace.py index 29a6c4b279..9856571d6f 100644 --- a/apiserver/plane/graphql/queries/workspace.py +++ b/apiserver/plane/graphql/queries/workspace.py @@ -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 diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index 95edd12e24..a4b1fc7078 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -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 diff --git a/apiserver/plane/graphql/types/cycle.py b/apiserver/plane/graphql/types/cycle.py index 59b08ec889..d0b033d3d3 100644 --- a/apiserver/plane/graphql/types/cycle.py +++ b/apiserver/plane/graphql/types/cycle.py @@ -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 diff --git a/apiserver/plane/graphql/types/estimate.py b/apiserver/plane/graphql/types/estimate.py new file mode 100644 index 0000000000..94d61156c8 --- /dev/null +++ b/apiserver/plane/graphql/types/estimate.py @@ -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 diff --git a/apiserver/plane/graphql/types/issue.py b/apiserver/plane/graphql/types/issue.py index 740916b71e..0fe305dd9a 100644 --- a/apiserver/plane/graphql/types/issue.py +++ b/apiserver/plane/graphql/types/issue.py @@ -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 diff --git a/apiserver/plane/graphql/types/link.py b/apiserver/plane/graphql/types/link.py new file mode 100644 index 0000000000..df43fc373e --- /dev/null +++ b/apiserver/plane/graphql/types/link.py @@ -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 diff --git a/apiserver/plane/graphql/types/module.py b/apiserver/plane/graphql/types/module.py index 141da5a496..47ef874034 100644 --- a/apiserver/plane/graphql/types/module.py +++ b/apiserver/plane/graphql/types/module.py @@ -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 diff --git a/apiserver/plane/graphql/types/paginator.py b/apiserver/plane/graphql/types/paginator.py new file mode 100644 index 0000000000..23029f8e3b --- /dev/null +++ b/apiserver/plane/graphql/types/paginator.py @@ -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] diff --git a/apiserver/plane/graphql/types/project.py b/apiserver/plane/graphql/types/project.py index 5e74d89804..7f79762fe8 100644 --- a/apiserver/plane/graphql/types/project.py +++ b/apiserver/plane/graphql/types/project.py @@ -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: diff --git a/apiserver/plane/graphql/types/users.py b/apiserver/plane/graphql/types/users.py index a799e2bf70..6f92ff9081 100644 --- a/apiserver/plane/graphql/types/users.py +++ b/apiserver/plane/graphql/types/users.py @@ -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 diff --git a/apiserver/plane/graphql/types/workspace.py b/apiserver/plane/graphql/types/workspace.py index 463657f32b..e9cab264db 100644 --- a/apiserver/plane/graphql/types/workspace.py +++ b/apiserver/plane/graphql/types/workspace.py @@ -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 diff --git a/apiserver/plane/graphql/utils/issue.py b/apiserver/plane/graphql/utils/issue.py new file mode 100644 index 0000000000..2f6cdccf84 --- /dev/null +++ b/apiserver/plane/graphql/utils/issue.py @@ -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 diff --git a/apiserver/plane/graphql/utils/paginator.py b/apiserver/plane/graphql/utils/paginator.py new file mode 100644 index 0000000000..9fc8ffc2de --- /dev/null +++ b/apiserver/plane/graphql/utils/paginator.py @@ -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, + ) diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index f9e8fdd609..523c4be0fa 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -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); diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index ac804b9b14..b26fac3844 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -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; }; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index a553f22899..125e0617b4 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -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; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 00e8c3e2a6..0059e64983 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -35,6 +35,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { tabIndex, closeOnSelect, openOnHover = false, + useCaptureForOutsideClick = false, } = props; const [referenceElement, setReferenceElement] = React.useState(null); @@ -88,7 +89,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { } }; - useOutsideClickDetector(dropdownRef, closeDropdown); + useOutsideClickDetector(dropdownRef, closeDropdown, useCaptureForOutsideClick); let menuItems = ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 8566f183b2..56963cbf4b 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -17,6 +17,7 @@ export interface IDropdownProps { optionsClassName?: string; placement?: Placement; tabIndex?: number; + useCaptureForOutsideClick?: boolean; } export interface ICustomMenuDropdownProps extends IDropdownProps { diff --git a/packages/ui/src/hooks/use-outside-click-detector.tsx b/packages/ui/src/hooks/use-outside-click-detector.tsx index c1a4778037..608b88df0a 100644 --- a/packages/ui/src/hooks/use-outside-click-detector.tsx +++ b/packages/ui/src/hooks/use-outside-click-detector.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; // TODO: move it to helpers package -const useOutsideClickDetector = (ref: React.RefObject, callback: () => void) => { +const useOutsideClickDetector = (ref: React.RefObject, 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, callback: () }; useEffect(() => { - document.addEventListener("mousedown", handleClick); + document.addEventListener("mousedown", handleClick, useCapture); return () => { - document.removeEventListener("mousedown", handleClick); + document.removeEventListener("mousedown", handleClick, useCapture); }; }); }; diff --git a/space/ee/components/issue-layouts/filters/header/filters/state.tsx b/space/ee/components/issue-layouts/filters/header/filters/state.tsx index da63258063..aa96452af4 100644 --- a/space/ee/components/issue-layouts/filters/header/filters/state.tsx +++ b/space/ee/components/issue-layouts/filters/header/filters/state.tsx @@ -20,7 +20,7 @@ type Props = { export const FilterState: React.FC = 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 = 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(() => { diff --git a/web/app/[workspaceSlug]/(pages)/pages/layout.tsx b/web/app/[workspaceSlug]/(pages)/pages/layout.tsx index 7cec2731cc..6fb045303a 100644 --- a/web/app/[workspaceSlug]/(pages)/pages/layout.tsx +++ b/web/app/[workspaceSlug]/(pages)/pages/layout.tsx @@ -26,7 +26,7 @@ export default function WorkspacePagesLayout({ children }: { children: React.Rea > <> -
+
{children} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx index e63cefa098..61aeb3e01f 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx @@ -71,7 +71,7 @@ const CycleDetailPage = observer(() => { {cycleId && !isSidebarCollapsed && (
{ {moduleId && !isSidebarCollapsed && (
{ <>
-
+
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index c04c6d9448..4cfe11d353 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -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 (
@@ -157,7 +160,7 @@ export const PageDetailsHeader = observer(() => {
- {isContentEditable && ( + {isContentEditable && !isVersionHistoryOverlayActive && (
-
{children}
+
{children}
diff --git a/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx b/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx index 1f8a64222b..9852f9ca6b 100644 --- a/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx +++ b/web/ce/components/workspace/upgrade/pro-plan-upgrade.tsx @@ -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 = (props) => { const { basePlan, features, verticalFeatureList = false, extraFeatures } = props; // states const [selectedPlan, setSelectedPlan] = useState("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 = (props) => { {price.recurring === "year" && ("Yearly" as string)} {price.recurring === "year" && ( - -28% + -{yearlyDiscount}% )} @@ -69,8 +83,8 @@ export const ProPlanUpgrade: FC = (props) => {
Plane Pro
- {price.recurring === "month" && "$7"} - {price.recurring === "year" && "$5"} + {price.currency} + {price.price}
a user per month
diff --git a/web/core/components/core/render-if-visible-HOC.tsx b/web/core/components/core/render-if-visible-HOC.tsx index a2259c6ca5..18c071d146 100644 --- a/web/core/components/core/render-if-visible-HOC.tsx +++ b/web/core/components/core/render-if-visible-HOC.tsx @@ -10,6 +10,7 @@ type Props = { as?: keyof JSX.IntrinsicElements; classNames?: string; placeholderChildren?: ReactNode; + defaultValue?: boolean; }; const RenderIfVisible: React.FC = (props) => { @@ -20,10 +21,11 @@ const RenderIfVisible: React.FC = (props) => { horizontalOffset = 0, as = "div", children, + defaultValue = false, classNames = "", placeholderChildren = null, //placeholder children } = props; - const [shouldVisible, setShouldVisible] = useState(); + const [shouldVisible, setShouldVisible] = useState(defaultValue); const placeholderHeight = useRef(defaultHeight); const intersectionRef = useRef(null); diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index c50982bc82..2395488fcd 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -46,7 +46,7 @@ const cycleService = new CycleService(); // TODO: refactor the whole component export const CycleDetailsSidebar: React.FC = 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 = observer((props) => { <>
); -}); +}); \ No newline at end of file diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx index 9040ab268b..d8c86ec182 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx @@ -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 = (props) => { const editorRef = useRef(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 = (props) => { return (
{ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting) handleSubmit(onSubmit)(e); diff --git a/web/core/components/issues/issue-layouts/list/block-root.tsx b/web/core/components/issues/issue-layouts/list/block-root.tsx index 2302e9e2c8..71bcc6f15c 100644 --- a/web/core/components/issues/issue-layouts/list/block-root.tsx +++ b/web/core/components/issues/issue-layouts/list/block-root.tsx @@ -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 = observer((props) => { @@ -56,6 +57,7 @@ export const IssueBlockRoot: FC = observer((props) => { isParentIssueBeingDragged = false, isLastChild = false, selectionHelpers, + shouldRenderByDefault, } = props; // states const [isExpanded, setExpanded] = useState(false); @@ -114,7 +116,7 @@ export const IssueBlockRoot: FC = 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 = observer((props) => { root={containerRef} classNames={`relative ${isLastChild && !isExpanded ? "" : "border-b border-b-custom-border-200"}`} verticalOffset={100} + defaultValue={shouldRenderByDefault || isIssueNew(issuesMap[issueId])} > = observer((props) => { isDragAllowed={isDragAllowed} canDropOverIssue={canDropOverIssue} isParentIssueBeingDragged={isParentIssueBeingDragged || isCurrentBlockDragging} + shouldRenderByDefault={isExpanded} /> ))} {isLastChild && } diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index f0d998f2b1..f0cef5b80b 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -166,6 +166,7 @@ export const AllIssueQuickActions: React.FC = observer((props placement={placements} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index 2134da80f2..ece43e585e 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -125,6 +125,7 @@ export const ArchivedIssueQuickActions: React.FC = observer(( placement={placements} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index 842a91bebd..a97377644e 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -187,6 +187,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro portalElement={portalElement} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx index cdf5c4bc6a..58807aee24 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/draft-issue.tsx @@ -115,6 +115,7 @@ export const DraftIssueQuickActions: React.FC = observer((pro placement={placements} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 17d56c8f50..f5368e3504 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -183,6 +183,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr portalElement={portalElement} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 4b191fb5cb..0ec40c2a57 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -177,6 +177,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p portalElement={portalElement} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { diff --git a/web/core/components/issues/issue-layouts/utils.tsx b/web/core/components/issues/issue-layouts/utils.tsx index 7840bb4621..ec333ef923 100644 --- a/web/core/components/issues/issue-layouts/utils.tsx +++ b/web/core/components/issues/issue-layouts/utils.tsx @@ -606,4 +606,16 @@ export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => { } return true; -}; \ No newline at end of file +}; + +/** + * 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; +}; diff --git a/web/core/components/modules/analytics-sidebar/root.tsx b/web/core/components/modules/analytics-sidebar/root.tsx index 7911485954..530ef89813 100644 --- a/web/core/components/modules/analytics-sidebar/root.tsx +++ b/web/core/components/modules/analytics-sidebar/root.tsx @@ -68,7 +68,7 @@ type Props = { // TODO: refactor this component export const ModuleAnalyticsSidebar: React.FC = 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 = observer((props) => { setModuleDeleteModal(false)} data={moduleDetails} /> <>
+
+
+ ) : ( + <> +
+
+ {isCurrentVersionActive + ? "Current version" + : versionDetails + ? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}` + : "Loading version details"} +
+ {!isCurrentVersionActive && ( + + )} +
+
+ +
+ + )} +
+ ); +}); diff --git a/web/core/components/pages/version/root.tsx b/web/core/components/pages/version/root.tsx new file mode 100644 index 0000000000..443053d56d --- /dev/null +++ b/web/core/components/pages/version/root.tsx @@ -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; + fetchVersionDetails: (pageId: string, versionId: string) => Promise; + handleRestore: (descriptionHTML: string) => Promise; + isOpen: boolean; + onClose: () => void; + pageId: string; +}; + +export const PageVersionsOverlay: React.FC = (props) => { + const { activeVersion, fetchAllVersions, fetchVersionDetails, handleRestore, isOpen, onClose, pageId } = props; + + const handleClose = () => { + onClose(); + }; + + return ( +
+ + +
+ ); +}; diff --git a/web/core/components/pages/version/sidebar-list-item.tsx b/web/core/components/pages/version/sidebar-list-item.tsx new file mode 100644 index 0000000000..9c1c13e0de --- /dev/null +++ b/web/core/components/pages/version/sidebar-list-item.tsx @@ -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 = observer((props) => { + const { href, isActive, version } = props; + // store hooks + const { getUserDetails } = useMember(); + // derived values + const ownerDetails = getUserDetails(version.owned_by); + + return ( + +

+ {renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)} +

+

+ + {ownerDetails?.display_name} +

+ + ); +}); diff --git a/web/core/components/pages/version/sidebar-list.tsx b/web/core/components/pages/version/sidebar-list.tsx new file mode 100644 index 0000000000..cf276742b0 --- /dev/null +++ b/web/core/components/pages/version/sidebar-list.tsx @@ -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; + isOpen: boolean; + pageId: string; +}; + +export const PageVersionsSidebarList: React.FC = (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 ( +
+ +

Current version

+ + {versionsListError ? ( +
+
+ + + +
+
Something went wrong!
+

+ There was a problem while loading previous +
+ versions, please try again. +

+
+ +
+
+ ) : versionsList ? ( + versionsList.map((version) => ( + + )) + ) : ( + + + + + + + + )} +
+ ); +}; diff --git a/web/core/components/pages/version/sidebar-root.tsx b/web/core/components/pages/version/sidebar-root.tsx new file mode 100644 index 0000000000..793d7fed90 --- /dev/null +++ b/web/core/components/pages/version/sidebar-root.tsx @@ -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; + handleClose: () => void; + isOpen: boolean; + pageId: string; +}; + +export const PageVersionsSidebarRoot: React.FC = (props) => { + const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props; + + return ( +
+
+
Version history
+ +
+ +
+ ); +}; diff --git a/web/core/components/profile/notification/email-notification-form.tsx b/web/core/components/profile/notification/email-notification-form.tsx index 4166634a9d..990ade067a 100644 --- a/web/core/components/profile/notification/email-notification-form.tsx +++ b/web/core/components/profile/notification/email-notification-form.tsx @@ -21,11 +21,9 @@ export const EmailNotificationForm: FC = (props) => // form data const { handleSubmit, - watch, control, - setValue, reset, - formState: { isSubmitting, isDirty, dirtyFields }, + formState: { isSubmitting, dirtyFields }, } = useForm({ defaultValues: { ...data, @@ -93,9 +91,7 @@ export const EmailNotificationForm: FC = (props) => render={({ field: { value, onChange } }) => ( { - setValue("issue_completed", !value, { shouldDirty: true }); onChange(!value); }} containerClassName="mx-2" @@ -155,7 +151,7 @@ export const EmailNotificationForm: FC = (props) =>
-
diff --git a/web/core/hooks/use-multiple-select.ts b/web/core/hooks/use-multiple-select.ts index 278304c4fe..1e997bbfe1 100644 --- a/web/core/hooks/use-multiple-select.ts +++ b/web/core/hooks/use-multiple-select.ts @@ -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( diff --git a/web/core/hooks/use-page-description.ts b/web/core/hooks/use-page-description.ts index f7b467d4d0..4273694505 100644 --- a/web/core/hooks/use-page-description.ts +++ b/web/core/hooks/use-page-description.ts @@ -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 ?? "

"); + const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema); + + try { + editorRef.current?.clearEditor(true); + await updateDescription(yDocBinaryString, descriptionHTML ?? "

"); + await mutateDescriptionYJS(); + } catch (error) { + console.log("error", error); + } + }; + useAutoSave(handleSaveDescription); return { @@ -190,5 +202,6 @@ export const usePageDescription = (props: Props) => { isDescriptionReady, pageDescriptionYJS, handleSaveDescription, + manuallyUpdateDescription, }; }; diff --git a/web/core/hooks/use-query-params.ts b/web/core/hooks/use-query-params.ts new file mode 100644 index 0000000000..8b689f0cbe --- /dev/null +++ b/web/core/hooks/use-query-params.ts @@ -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, + }; +}; diff --git a/web/core/hooks/use-reload-confirmation.tsx b/web/core/hooks/use-reload-confirmation.tsx index 998c5845d9..6ba324d148 100644 --- a/web/core/hooks/use-reload-confirmation.tsx +++ b/web/core/hooks/use-reload-confirmation.tsx @@ -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(); } diff --git a/web/core/services/page/index.ts b/web/core/services/page/index.ts index d89b175d63..b25199e7f3 100644 --- a/web/core/services/page/index.ts +++ b/web/core/services/page/index.ts @@ -1 +1,2 @@ +export * from "./project-page-version.service"; export * from "./project-page.service"; diff --git a/web/core/services/page/project-page-version.service.ts b/web/core/services/page/project-page-version.service.ts new file mode 100644 index 0000000000..05732e3d22 --- /dev/null +++ b/web/core/services/page/project-page-version.service.ts @@ -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 { + 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 { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/${versionId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/components/estimates/points/switch/root.tsx b/web/ee/components/estimates/points/switch/root.tsx index 424248e628..ec3af13c0d 100644 --- a/web/ee/components/estimates/points/switch/root.tsx +++ b/web/ee/components/estimates/points/switch/root.tsx @@ -71,6 +71,7 @@ export const EstimatePointSwitchRoot: FC = observer((p handleEstimatePointError(prevValue[index].key, "", "", undefined, "delete"); return prevValue; }); + setSwitchLoader(false); }; const isValidEstimatePoints = (estimateSystemSwitchType: TEstimateSystemKeys) => { @@ -168,6 +169,8 @@ export const EstimatePointSwitchRoot: FC = observer((p }); handleClose(); setSwitchLoader(false); + } else { + setSwitchLoader(false); } } catch (error) { setToast({ diff --git a/web/ee/components/license/free-trial-banner.tsx b/web/ee/components/license/free-trial-banner.tsx index a6120f37e5..2dcdce5537 100644 --- a/web/ee/components/license/free-trial-banner.tsx +++ b/web/ee/components/license/free-trial-banner.tsx @@ -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 + .