From 68cf9e7732b2314214799c30faa3559765ecfffb Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 16 Aug 2024 18:19:13 +0530 Subject: [PATCH 01/22] fix states order in filters in space app (#850) --- .../components/issue-layouts/filters/header/filters/state.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(() => { From 357061e558b7edae70eb3ed9b6e34a26b9fae62a Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Mon, 19 Aug 2024 19:29:51 +0530 Subject: [PATCH 02/22] chore: issues pagination and issues information based on groupBy (#846) * chore: implementes pagination for project issues * chore: added new user properties * chore: updated pagination for projects, pages, and workspace, project, cycle, and module issues * chore: created new query to get issues info in project level * fix: resloved the merge conflict * chore: updated issues information for workspace, module, and cycle * chore: addes total active issues in projects list * chore: updated frontend changes * chore: typo in pro cloud tooltip * chore: handled the optional type validation --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/graphql/mutations/issue.py | 8 +- apiserver/plane/graphql/mutations/module.py | 2 - apiserver/plane/graphql/queries/cycle.py | 122 +++++++++++++++- apiserver/plane/graphql/queries/issue.py | 140 +++++++++++++------ apiserver/plane/graphql/queries/link.py | 35 +++++ apiserver/plane/graphql/queries/module.py | 123 +++++++++++++++- apiserver/plane/graphql/queries/page.py | 17 ++- apiserver/plane/graphql/queries/project.py | 20 ++- apiserver/plane/graphql/queries/workspace.py | 65 +++++++-- apiserver/plane/graphql/schema.py | 21 ++- apiserver/plane/graphql/types/issue.py | 35 ++++- apiserver/plane/graphql/types/link.py | 45 ++++++ apiserver/plane/graphql/types/paginator.py | 27 ++++ apiserver/plane/graphql/types/project.py | 10 ++ apiserver/plane/graphql/utils/issue.py | 87 ++++++++++++ apiserver/plane/graphql/utils/paginator.py | 79 +++++++++++ 16 files changed, 749 insertions(+), 87 deletions(-) create mode 100644 apiserver/plane/graphql/queries/link.py create mode 100644 apiserver/plane/graphql/types/link.py create mode 100644 apiserver/plane/graphql/types/paginator.py create mode 100644 apiserver/plane/graphql/utils/issue.py create mode 100644 apiserver/plane/graphql/utils/paginator.py diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index d07d49eff3..3d49e9c73d 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -15,7 +15,7 @@ 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, @@ -52,7 +52,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, @@ -137,7 +137,7 @@ class IssueMutation: 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: @@ -270,7 +270,6 @@ class IssueAttachmentMutation: # ) -> IssueAttachment: # pass - # @strawberry.mutation( # extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] # ) @@ -283,7 +282,6 @@ class IssueAttachmentMutation: # return True - @strawberry.mutation( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] ) diff --git a/apiserver/plane/graphql/mutations/module.py b/apiserver/plane/graphql/mutations/module.py index 6548be37f7..4528d8c56d 100644 --- a/apiserver/plane/graphql/mutations/module.py +++ b/apiserver/plane/graphql/mutations/module.py @@ -39,9 +39,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( diff --git a/apiserver/plane/graphql/queries/cycle.py b/apiserver/plane/graphql/queries/cycle.py index 6c806eaf0f..8a4a1a3116 100644 --- a/apiserver/plane/graphql/queries/cycle.py +++ b/apiserver/plane/graphql/queries/cycle.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 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,7 +38,6 @@ class CycleQuery: slug: str, project: strawberry.ID, ) -> list[CycleType]: - cycles = await sync_to_async(list)( Cycle.objects.filter(workspace__slug=slug) .filter(project_id=project) @@ -57,6 +68,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 +159,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/issue.py b/apiserver/plane/graphql/queries/issue.py index 1b0eb18794..5a10e50fd4 100644 --- a/apiserver/plane/graphql/queries/issue.py +++ b/apiserver/plane/graphql/queries/issue.py @@ -15,7 +15,9 @@ from django.db.models import Prefetch, Q # Module Imports from plane.graphql.types.issue import ( - IssueType, + IssuesInformationType, + IssuesInformationObjectType, + IssuesType, IssueUserPropertyType, IssueCommentActivityType, IssuePropertyActivityType, @@ -30,8 +32,87 @@ from plane.db.models import ( 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 +125,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 +149,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 +161,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 +179,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 +203,7 @@ class RecentIssuesQuery: ).filter( project__project_projectmember__member=info.context.user, project__project_projectmember__is_active=True, - )[ - :5 - ] + )[:5] ) return issues @@ -139,9 +219,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 +229,10 @@ class IssueUserPropertyQuery: project__project_projectmember__is_active=True, ) .order_by("-created_at") - ) - return issue_properties + .first() + )() + + return issue_property @strawberry.type @@ -215,31 +297,3 @@ class IssueCommentActivityQuery: ) return issue_comments - - -# User profile issues -@strawberry.type -class WorkspaceIssuesQuery: - @strawberry.field( - extensions=[ - PermissionExtension(permissions=[WorkspaceBasePermission()]) - ] - ) - async def workspace_issues( - self, - info: Info, - slug: str, - filters: Optional[JSON] = {}, - orderBy: Optional[str] = "-created_at", - ) -> list[IssueType]: - issues = await sync_to_async(list)( - Issue.issue_objects.filter( - project__project_projectmember__member=info.context.user, - project__projectmember__is_active=True, - workspace__slug=slug, - ) - .select_related("actor", "issue", "project", "workspace") - .order_by(orderBy, "-created_at") - .filter(**filters) - ) - return issues diff --git a/apiserver/plane/graphql/queries/link.py b/apiserver/plane/graphql/queries/link.py new file mode 100644 index 0000000000..d24741dbf4 --- /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 + ) + ) + return issue_links diff --git a/apiserver/plane/graphql/queries/module.py b/apiserver/plane/graphql/queries/module.py index 7b68a12546..b4b1c99d06 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 @@ -26,7 +38,6 @@ class ModuleQuery: slug: str, project: strawberry.ID, ) -> list[ModuleType]: - modules = await sync_to_async(list)( Module.objects.filter(workspace__slug=slug) .filter(project_id=project) @@ -57,6 +68,86 @@ class ModuleQuery: return module +# 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 +159,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..cfc9d95d79 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 @@ -13,17 +16,22 @@ from django.db.models import Exists, OuterRef, Q from plane.graphql.types.page import PageType from plane.db.models import UserFavorite, Page from plane.graphql.permissions.project import ProjectBasePermission +from plane.graphql.types.paginator import PaginatorResponse +from plane.graphql.utils.paginator import paginate @strawberry.type class PageQuery: - @strawberry.field( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] ) 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,7 +51,8 @@ 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()])] diff --git a/apiserver/plane/graphql/queries/project.py b/apiserver/plane/graphql/queries/project.py index 2d86f4f3f5..a59713428b 100644 --- a/apiserver/plane/graphql/queries/project.py +++ b/apiserver/plane/graphql/queries/project.py @@ -8,23 +8,30 @@ 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]: + async def projects( + self, + info: Info, + slug: str, + cursor: Optional[str] = None, + ) -> PaginatorResponse[ProjectType]: project = await sync_to_async(list)( Project.objects.filter(workspace__slug=slug) .filter( @@ -54,19 +61,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 +81,5 @@ class ProjectMembersQuery: member__is_bot=False, ) ) - return project + + return project_members diff --git a/apiserver/plane/graphql/queries/workspace.py b/apiserver/plane/graphql/queries/workspace.py index 29a6c4b279..5fd9b14d89 100644 --- a/apiserver/plane/graphql/queries/workspace.py +++ b/apiserver/plane/graphql/queries/workspace.py @@ -13,16 +13,22 @@ from strawberry.permission import PermissionExtension from plane.graphql.types.workspace import WorkspaceType, WorkspaceMemberType from plane.db.models import Workspace, WorkspaceMember, Issue 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 +39,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 +62,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 +131,5 @@ class WorkspaceIssuesQuery: .order_by(orderBy, "-created_at") .filter(**filters) ) - return issues + + return paginate(results_object=issues, cursor=cursor) diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index 95edd12e24..c81b1659f0 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -5,6 +5,7 @@ from strawberry_django.optimizer import DjangoOptimizerExtension from .queries.workspace import ( WorkspaceQuery, WorkspaceMembersQuery, + WorkspaceIssuesInformationQuery, WorkspaceIssuesQuery, ) from .queries.users import UserQuery @@ -14,6 +15,7 @@ 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, @@ -21,10 +23,19 @@ from .queries.issue import ( IssueCommentActivityQuery, ) from .queries.page import PageQuery -from .queries.cycle import CycleQuery, CycleIssueQuery -from .queries.module import ModuleQuery, ModuleIssueQuery +from .queries.cycle import ( + CycleQuery, + CycleIssuesInformationQuery, + CycleIssueQuery, +) +from .queries.module import ( + ModuleQuery, + ModuleIssuesInformationQuery, + ModuleIssueQuery, +) from .queries.search import ProjectSearchQuery from .queries.attachment import IssueAttachmentQuery +from .queries.link import IssueLinkQuery # mutations from .mutations.workspace import WorkspaceMutation, WorkspaceInviteMutation @@ -44,6 +55,7 @@ from .mutations.page import PageFavoriteMutation from .mutations.cycle import CycleIssueMutation from .mutations.module import ModuleIssueMutation + # combined query class for all @strawberry.type class Query( @@ -71,6 +83,11 @@ class Query( CycleIssueQuery, ModuleQuery, ModuleIssueQuery, + IssueLinkQuery, + IssuesInformationQuery, + WorkspaceIssuesInformationQuery, + CycleIssuesInformationQuery, + ModuleIssuesInformationQuery, ): pass diff --git a/apiserver/plane/graphql/types/issue.py b/apiserver/plane/graphql/types/issue.py index 740916b71e..48c979ad08 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,16 @@ 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 + sort_order: float + is_default: bool + weight: int + is_active: bool 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/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/utils/issue.py b/apiserver/plane/graphql/utils/issue.py new file mode 100644 index 0000000000..d6fcd0bcff --- /dev/null +++ b/apiserver/plane/graphql/utils/issue.py @@ -0,0 +1,87 @@ +# 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 = "label__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")) + ) + + 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, + ) From 23309ccc30479d02cf066450613e9c147069552c Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 21 Aug 2024 17:50:50 +0530 Subject: [PATCH 03/22] chore: mobile queries for home screen and updated module, cycle, queries (#898) * chore: home screen your work and favorites queries * chore: user recent and use favorites * chore: updated your work and user favorites * chore: handled entity_data in user recent visits * chore: moved queries from home to workspace and user * chore: labels and assignees made optional in issue update * chore: added type in projects list query * chore: module query updates * chore: moved the list of cycles to current and upcoming cycles * chore: typo --- .../plane/app/views/workspace/favorite.py | 2 - apiserver/plane/graphql/mutations/cycle.py | 5 +- apiserver/plane/graphql/mutations/issue.py | 5 +- apiserver/plane/graphql/queries/cycle.py | 20 +- apiserver/plane/graphql/queries/module.py | 29 ++- apiserver/plane/graphql/queries/project.py | 19 +- apiserver/plane/graphql/queries/users.py | 87 +++++++- apiserver/plane/graphql/queries/workspace.py | 72 ++++++- apiserver/plane/graphql/schema.py | 6 +- apiserver/plane/graphql/types/cycle.py | 3 +- apiserver/plane/graphql/types/module.py | 1 + apiserver/plane/graphql/types/users.py | 198 +++++++++++++++++- apiserver/plane/graphql/types/workspace.py | 8 + 13 files changed, 427 insertions(+), 28 deletions(-) diff --git a/apiserver/plane/app/views/workspace/favorite.py b/apiserver/plane/app/views/workspace/favorite.py index 204dbfc3c2..1067e9fa33 100644 --- a/apiserver/plane/app/views/workspace/favorite.py +++ b/apiserver/plane/app/views/workspace/favorite.py @@ -13,7 +13,6 @@ from plane.app.permissions import allow_permission, ROLE class WorkspaceFavoriteEndpoint(BaseAPIView): - @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" ) @@ -76,7 +75,6 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): class WorkspaceFavoriteGroupEndpoint(BaseAPIView): - @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE" ) diff --git a/apiserver/plane/graphql/mutations/cycle.py b/apiserver/plane/graphql/mutations/cycle.py index f555c6c2d3..0bf5972ffa 100644 --- a/apiserver/plane/graphql/mutations/cycle.py +++ b/apiserver/plane/graphql/mutations/cycle.py @@ -134,7 +134,10 @@ class CycleIssueMutation: issue: strawberry.ID, ) -> bool: cycle_issue = await sync_to_async(CycleIssue.objects.filter)( - cycle_id=cycle, project_id=project, workspace__slug=slug, issue_id=issue + cycle_id=cycle, + project_id=project, + workspace__slug=slug, + issue_id=issue, ) await sync_to_async(issue_activity.delay)( type="cycle.activity.deleted", diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index 3d49e9c73d..b844050df3 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -130,8 +130,8 @@ 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, @@ -293,7 +293,6 @@ class IssueAttachmentMutation: issue: strawberry.ID, attachment: strawberry.ID, ) -> bool: - issue_attachment = await sync_to_async(IssueAttachment.objects.get)( id=attachment, issue_id=issue, diff --git a/apiserver/plane/graphql/queries/cycle.py b/apiserver/plane/graphql/queries/cycle.py index 8a4a1a3116..a171778181 100644 --- a/apiserver/plane/graphql/queries/cycle.py +++ b/apiserver/plane/graphql/queries/cycle.py @@ -7,6 +7,10 @@ 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 @@ -38,13 +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 diff --git a/apiserver/plane/graphql/queries/module.py b/apiserver/plane/graphql/queries/module.py index b4b1c99d06..c3c5b13de0 100644 --- a/apiserver/plane/graphql/queries/module.py +++ b/apiserver/plane/graphql/queries/module.py @@ -37,7 +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) @@ -46,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()])] @@ -67,6 +69,29 @@ 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 diff --git a/apiserver/plane/graphql/queries/project.py b/apiserver/plane/graphql/queries/project.py index a59713428b..a141c1b2ca 100644 --- a/apiserver/plane/graphql/queries/project.py +++ b/apiserver/plane/graphql/queries/project.py @@ -30,17 +30,25 @@ class ProjectQuery: self, info: Info, slug: str, + type: Optional[str] = "all", cursor: Optional[str] = None, ) -> PaginatorResponse[ProjectType]: - project = await sync_to_async(list)( - Project.objects.filter(workspace__slug=slug) - .filter( + 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, @@ -49,8 +57,7 @@ class ProjectQuery: project_id=OuterRef("pk"), ) ) - ) - .annotate( + ).annotate( is_member=Exists( ProjectMember.objects.filter( member=info.context.user, 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 5fd9b14d89..9856571d6f 100644 --- a/apiserver/plane/graphql/queries/workspace.py +++ b/apiserver/plane/graphql/queries/workspace.py @@ -5,13 +5,19 @@ 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 ( IssuesInformationType, @@ -133,3 +139,63 @@ class WorkspaceIssuesQuery: ) 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 c81b1659f0..de8d288c51 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -7,8 +7,9 @@ from .queries.workspace import ( 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 @@ -83,6 +84,9 @@ class Query( CycleIssueQuery, ModuleQuery, ModuleIssueQuery, + YourWorkQuery, + UserFavoritesQuery, + UserRecentVisitQuery, IssueLinkQuery, IssuesInformationQuery, WorkspaceIssuesInformationQuery, diff --git a/apiserver/plane/graphql/types/cycle.py b/apiserver/plane/graphql/types/cycle.py index 59b08ec889..211a529bdf 100644 --- a/apiserver/plane/graphql/types/cycle.py +++ b/apiserver/plane/graphql/types/cycle.py @@ -69,8 +69,7 @@ class CycleType: async def completed_issues(self, info: Info) -> int: total_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 diff --git a/apiserver/plane/graphql/types/module.py b/apiserver/plane/graphql/types/module.py index 141da5a496..3ce4b1e34f 100644 --- a/apiserver/plane/graphql/types/module.py +++ b/apiserver/plane/graphql/types/module.py @@ -14,6 +14,7 @@ from plane.db.models import Module, Issue # Third-party library imports from asgiref.sync import sync_to_async + @strawberry_django.type(Module) class ModuleType: name: str diff --git a/apiserver/plane/graphql/types/users.py b/apiserver/plane/graphql/types/users.py index a799e2bf70..f04e861336 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,187 @@ 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] + + @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] + + @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..62019a5189 100644 --- a/apiserver/plane/graphql/types/workspace.py +++ b/apiserver/plane/graphql/types/workspace.py @@ -36,3 +36,11 @@ class WorkspaceMemberType: 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 From 48e9042970dbf4e0ca157f38ad69e189a8db5dd0 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:33:14 +0530 Subject: [PATCH 04/22] [WEB-2289] fix: email notification settings form validation (#5413) * fix: email notification validation * chore: code refactor --- .../profile/notification/email-notification-form.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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) =>
-
From d358f89065f0657d6ec5b7633374d03cc7f3ca31 Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:11:18 +0530 Subject: [PATCH 05/22] fix: only members can now be made leads (#913) --- web/ee/components/projects/layouts/attributes.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/ee/components/projects/layouts/attributes.tsx b/web/ee/components/projects/layouts/attributes.tsx index 22c2d7028d..0955830572 100644 --- a/web/ee/components/projects/layouts/attributes.tsx +++ b/web/ee/components/projects/layouts/attributes.tsx @@ -101,6 +101,7 @@ const Attributes: React.FC = observer((props) => { tabIndex={5} buttonClassName="z-1 px-2 py-0 h-5" className="h-5 my-auto" + projectId={project.id} disabled={!isEditingAllowed || isArchived} showTooltip button={ From f0452d65d3e4ab578c61c4819f831b4d858ccc93 Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Fri, 23 Aug 2024 16:09:23 +0530 Subject: [PATCH 06/22] [WEB-2301] fix: issue with state id in workspace projects (#914) * fix: state id fixed * fix: added loading for project states * fix: removed unwanted checks * fix: changed title to list --- web/core/layouts/auth-layout/workspace-wrapper.tsx | 2 +- web/ee/components/projects/header/layout-selection.tsx | 1 - web/ee/components/projects/layouts/board/utils.tsx | 2 +- web/ee/components/projects/layouts/project-layout-HOC.tsx | 6 ++++-- web/ee/constants/project/default-root.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/core/layouts/auth-layout/workspace-wrapper.tsx b/web/core/layouts/auth-layout/workspace-wrapper.tsx index 0c111d0a00..070659bbee 100644 --- a/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -70,7 +70,7 @@ export const WorkspaceAuthWrapper: FC = observer((props) ); // fetch project states useSWR( - workspaceSlug && currentWorkspace && isProjectStateEnabled ? `WORKSPACE_WORKLOGS_${workspaceSlug}` : null, + workspaceSlug && currentWorkspace && isProjectStateEnabled ? `WORKSPACE_PROJECT_STATES_${workspaceSlug}` : null, () => workspaceSlug && currentWorkspace && isProjectStateEnabled ? fetchProjectStates(workspaceSlug.toString()) : null, { revalidateOnFocus: false } diff --git a/web/ee/components/projects/header/layout-selection.tsx b/web/ee/components/projects/header/layout-selection.tsx index 8aa05cbbcf..74dbf381b4 100644 --- a/web/ee/components/projects/header/layout-selection.tsx +++ b/web/ee/components/projects/header/layout-selection.tsx @@ -25,7 +25,6 @@ export const ProjectLayoutSelection: FC = observer((pro // derived values const selectedLayout = filters?.layout || EProjectLayouts.TABLE; - console.log("selectedLayout", selectedLayout); return ( <> , prePopulatedPayload: { - state_id: states && states[0].id, + state_id: states && states.length > 0 && states[0].id, }, }; } diff --git a/web/ee/components/projects/layouts/project-layout-HOC.tsx b/web/ee/components/projects/layouts/project-layout-HOC.tsx index bbcd231f15..0541c5b749 100644 --- a/web/ee/components/projects/layouts/project-layout-HOC.tsx +++ b/web/ee/components/projects/layouts/project-layout-HOC.tsx @@ -1,10 +1,11 @@ +import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import Image from "next/image"; import { EmptyState } from "@/components/empty-state"; import { GanttLayoutLoader, KanbanLayoutLoader, ListLayoutLoader, ProjectsLoader } from "@/components/ui"; import { EmptyStateType } from "@/constants/empty-state"; import { useCommandPalette, useEventTracker } from "@/hooks/store"; -import { useProjectFilter } from "@/plane-web/hooks/store"; +import { useProjectFilter, useWorkspaceProjectStates } from "@/plane-web/hooks/store"; import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; import AllFiltersImage from "@/public/empty-state/project/all-filters.svg"; @@ -33,12 +34,13 @@ export const ProjectLayoutHOC = observer((props: Props) => { const { layout } = props; const { loading } = useProjectFilter(); const { getFilteredProjectsByLayout } = useProjectFilter(); + const { projectStates } = useWorkspaceProjectStates(); const { setTrackElement } = useEventTracker(); const { toggleCreateProjectModal } = useCommandPalette(); const filteredProjectIds = getFilteredProjectsByLayout(EProjectLayouts.GALLERY); - if (loading) { + if (loading || isEmpty(projectStates)) { return ; } if (!filteredProjectIds) { diff --git a/web/ee/constants/project/default-root.ts b/web/ee/constants/project/default-root.ts index 93d2e88667..38a3acb90a 100644 --- a/web/ee/constants/project/default-root.ts +++ b/web/ee/constants/project/default-root.ts @@ -48,7 +48,7 @@ export const PROJECT_LAYOUT_MAP: TProjectLayoutMap = { }, [EProjectLayouts.TABLE]: { key: EProjectLayouts.TABLE, - title: "Table Layout", + title: "List Layout", label: "Table", icon: List, selectivelyHide: false, From adf891bcbac5ba7c5f47587b2daa9eed2f0cf620 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Fri, 23 Aug 2024 18:00:15 +0530 Subject: [PATCH 07/22] [WEB-2150] fix: issue selection redirect alert (#5406) * fix issue selection redirect alert * change message content for user prompt --- web/core/hooks/use-multiple-select.ts | 11 +++++++++++ web/core/hooks/use-reload-confirmation.tsx | 12 ++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) 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-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(); } From 700f3ee82393c5cbd4f1d5471daaca6de320ecaa Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 23 Aug 2024 18:04:55 +0530 Subject: [PATCH 08/22] chore: pricing update. (#5410) --- .../workspace/upgrade/pro-plan-upgrade.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) 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
From 34776f4bd3a6a7065e75c6b202fdacd00eb14954 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 23 Aug 2024 18:57:10 +0530 Subject: [PATCH 09/22] chore: added issue properties in the issue detail (#922) * chore: added issue properties in the issue detail * chore: global favorite endpoint * chore: null key validation for groupby * chore: added project id in the user recents --- apiserver/plane/app/views/project/base.py | 2 +- .../plane/bgtasks/issue_activities_task.py | 2 - apiserver/plane/graphql/mutations/cycle.py | 49 +++++++++++- apiserver/plane/graphql/mutations/favorite.py | 61 ++++++++++++++ apiserver/plane/graphql/mutations/issue.py | 57 ++++++++++--- apiserver/plane/graphql/mutations/link.py | 67 ++++++++++++++++ apiserver/plane/graphql/mutations/module.py | 49 +++++++++++- apiserver/plane/graphql/queries/estimate.py | 28 +++++++ apiserver/plane/graphql/queries/issue.py | 80 +++++++++++++++++++ apiserver/plane/graphql/queries/link.py | 2 +- apiserver/plane/graphql/queries/page.py | 47 ++++++++++- apiserver/plane/graphql/queries/relation.py | 0 apiserver/plane/graphql/schema.py | 21 ++++- apiserver/plane/graphql/types/estimate.py | 29 +++++++ apiserver/plane/graphql/types/issue.py | 8 +- apiserver/plane/graphql/types/users.py | 14 ++++ apiserver/plane/graphql/utils/issue.py | 5 +- 17 files changed, 489 insertions(+), 32 deletions(-) create mode 100644 apiserver/plane/graphql/mutations/favorite.py create mode 100644 apiserver/plane/graphql/mutations/link.py create mode 100644 apiserver/plane/graphql/queries/estimate.py create mode 100644 apiserver/plane/graphql/queries/relation.py create mode 100644 apiserver/plane/graphql/types/estimate.py diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 7deb5aa7bc..c77a950a78 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -614,8 +614,8 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView): project.archived_at = timezone.now() project.save() UserFavorite.objects.filter( + project_id=project_id, workspace__slug=slug, - project=project_id, ).delete() return Response( {"archived_at": str(project.archived_at)}, diff --git a/apiserver/plane/bgtasks/issue_activities_task.py b/apiserver/plane/bgtasks/issue_activities_task.py index 1e540bfddf..ae112fa63e 100644 --- a/apiserver/plane/bgtasks/issue_activities_task.py +++ b/apiserver/plane/bgtasks/issue_activities_task.py @@ -1,13 +1,11 @@ # Python imports import json -import requests # Third Party imports from celery import shared_task # Django imports -from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone diff --git a/apiserver/plane/graphql/mutations/cycle.py b/apiserver/plane/graphql/mutations/cycle.py index 0bf5972ffa..71ee75368a 100644 --- a/apiserver/plane/graphql/mutations/cycle.py +++ b/apiserver/plane/graphql/mutations/cycle.py @@ -18,11 +18,9 @@ from django.core import serializers # Module imports from plane.graphql.permissions.project import ( ProjectMemberPermission, + ProjectBasePermission, ) -from plane.db.models import ( - CycleIssue, - Cycle, -) +from plane.db.models import CycleIssue, Cycle, UserFavorite from plane.graphql.bgtasks.issue_activity_task import issue_activity @@ -158,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 7233def948..74803265ef 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -11,7 +11,6 @@ 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 IssuesType, IssueUserPropertyType @@ -26,6 +25,7 @@ from plane.db.models import ( IssueLabel, Workspace, IssueAttachment, + IssueSubscriber, ) @@ -262,12 +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()])] @@ -275,11 +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) +# # Save the file using Django's file storage +# await sync_to_async(default_storage.save)(filename, content) - # return True +# return True @strawberry.mutation( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] @@ -300,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 4528d8c56d..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 @@ -113,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/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 5a10e50fd4..8ead450bfa 100644 --- a/apiserver/plane/graphql/queries/issue.py +++ b/apiserver/plane/graphql/queries/issue.py @@ -21,6 +21,7 @@ from plane.graphql.types.issue import ( IssueUserPropertyType, IssueCommentActivityType, IssuePropertyActivityType, + IssueTypesType, ) from plane.db.models import ( Issue, @@ -28,6 +29,7 @@ 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 @@ -297,3 +299,81 @@ class IssueCommentActivityQuery: ) return issue_comments + + +# User profile issues +@strawberry.type +class WorkspaceIssuesQuery: + @strawberry.field( + extensions=[ + PermissionExtension(permissions=[WorkspaceBasePermission()]) + ] + ) + async def workspace_issues( + self, + info: Info, + slug: str, + filters: Optional[JSON] = {}, + orderBy: Optional[str] = "-created_at", + 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, + workspace__slug=slug, + ) + .select_related("actor", "issue", "project", "workspace") + .order_by(orderBy, "-created_at") + .filter(**filters) + ) + + 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 index d24741dbf4..62e975cecf 100644 --- a/apiserver/plane/graphql/queries/link.py +++ b/apiserver/plane/graphql/queries/link.py @@ -30,6 +30,6 @@ class IssueLinkQuery: 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/page.py b/apiserver/plane/graphql/queries/page.py index cfc9d95d79..e9a7f6c868 100644 --- a/apiserver/plane/graphql/queries/page.py +++ b/apiserver/plane/graphql/queries/page.py @@ -15,15 +15,52 @@ 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 PageQuery: +class UserPageQuery: @strawberry.field( - extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + 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=[WorkspaceBasePermission()]) + ] ) async def pages( self, @@ -55,7 +92,9 @@ class PageQuery: 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/relation.py b/apiserver/plane/graphql/queries/relation.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index de8d288c51..a4b1fc7078 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -22,8 +22,10 @@ from .queries.issue import ( IssueUserPropertyQuery, IssuePropertiesActivityQuery, IssueCommentActivityQuery, + SubIssuesQuery, + IssueTypesTypeQuery, ) -from .queries.page import PageQuery +from .queries.page import PageQuery, UserPageQuery from .queries.cycle import ( CycleQuery, CycleIssuesInformationQuery, @@ -37,6 +39,7 @@ from .queries.module import ( 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 @@ -49,12 +52,15 @@ 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 @@ -92,6 +98,10 @@ class Query( WorkspaceIssuesInformationQuery, CycleIssuesInformationQuery, ModuleIssuesInformationQuery, + SubIssuesQuery, + IssueTypesTypeQuery, + EstimatePointQuery, + UserPageQuery, ): pass @@ -112,6 +122,11 @@ class Mutation( IssueAttachmentMutation, CycleIssueMutation, ModuleIssueMutation, + IssueLinkMutation, + IssueSubscriptionMutation, + CycleFavoriteMutation, + ModuleFavoriteMutation, + UserFavoriteMutation, ): pass 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 48c979ad08..0fe305dd9a 100644 --- a/apiserver/plane/graphql/types/issue.py +++ b/apiserver/plane/graphql/types/issue.py @@ -254,7 +254,11 @@ class IssueTypesType: name: str description: str logo_props: JSON - sort_order: float is_default: bool - weight: int + level: int is_active: bool + + + @strawberry.field + def workspace(self) -> int: + return self.workspace_id diff --git a/apiserver/plane/graphql/types/users.py b/apiserver/plane/graphql/types/users.py index f04e861336..6f92ff9081 100644 --- a/apiserver/plane/graphql/types/users.py +++ b/apiserver/plane/graphql/types/users.py @@ -117,6 +117,11 @@ class UserFavoriteType: 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]: @@ -204,6 +209,15 @@ class UserRecentVisitType: 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]: diff --git a/apiserver/plane/graphql/utils/issue.py b/apiserver/plane/graphql/utils/issue.py index d6fcd0bcff..2f6cdccf84 100644 --- a/apiserver/plane/graphql/utils/issue.py +++ b/apiserver/plane/graphql/utils/issue.py @@ -19,7 +19,7 @@ from plane.db.models import Issue # Enum for grouping issues class IssuesGroupBy(Enum): PRIORITY = "priority" - LABELS = "label__id" + LABELS = "labels__id" STATE = "state__id" STATE_GROUP = "state__group" ASSIGNEES = "assignees__id" @@ -83,5 +83,8 @@ async def issue_information_query_execute( .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 From 9065932c863476bf0e4983d215b13860923185c7 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 23 Aug 2024 19:06:12 +0530 Subject: [PATCH 10/22] fix: pwa sticky issue comment (#5419) --- .../issue-activity/comments/comment-create.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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); From 7438fd680c67ef63a2ccec6eb238aef08ea863f1 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Mon, 26 Aug 2024 11:40:17 +0530 Subject: [PATCH 11/22] fix: pages app layout height (#925) --- web/app/[workspaceSlug]/(pages)/pages/layout.tsx | 2 +- web/ee/components/license/free-trial-banner.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/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 + .
+
+
+ ) : ( + <> +
+
+ {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/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/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; + }); + } +} From 890379b64fa6eb672d8ea3d473df4523023a8886 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:40:11 +0530 Subject: [PATCH 15/22] Make quick action dropdowns use capture phase of the event to trigger closure on outside click (#5414) --- packages/ui/src/dropdowns/custom-menu.tsx | 3 ++- packages/ui/src/dropdowns/helper.tsx | 1 + packages/ui/src/hooks/use-outside-click-detector.tsx | 6 +++--- .../issue-layouts/quick-action-dropdowns/all-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/archived-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/cycle-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/draft-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/module-issue.tsx | 1 + .../issue-layouts/quick-action-dropdowns/project-issue.tsx | 1 + 9 files changed, 12 insertions(+), 4 deletions(-) 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/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 2c12ed18be..9ecbe44d0f 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 @@ -163,6 +163,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 6e5187b64b..2a08bac9d1 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 @@ -183,6 +183,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 1bfa57d06a..8ec30042b7 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 @@ -180,6 +180,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 c47d76af77..40b53090be 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 @@ -174,6 +174,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p portalElement={portalElement} menuItemsClassName="z-[14]" maxHeight="lg" + useCaptureForOutsideClick closeOnSelect > {MENU_ITEMS.map((item) => { From 5be391efbfac80f70c2600ad219ffb30c86bac4b Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Mon, 26 Aug 2024 14:40:15 +0530 Subject: [PATCH 16/22] chore: cycle and module assignee and issue count (#933) * chore: cycle assignees count * chore: module assignees count * chore: module assignees count * chore: added owned by detail and led detail in cycle and module types * chore: updated leadDetail in module * chore: updated lead in module and owened_by in cycle query * chore: removed unused types in cycle --- apiserver/plane/graphql/types/cycle.py | 24 +++++++++++++++------- apiserver/plane/graphql/types/module.py | 16 ++++++++++++++- apiserver/plane/graphql/types/workspace.py | 4 ---- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apiserver/plane/graphql/types/cycle.py b/apiserver/plane/graphql/types/cycle.py index 211a529bdf..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,9 +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" ).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/module.py b/apiserver/plane/graphql/types/module.py index 3ce4b1e34f..47ef874034 100644 --- a/apiserver/plane/graphql/types/module.py +++ b/apiserver/plane/graphql/types/module.py @@ -10,6 +10,7 @@ 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 @@ -31,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 @@ -41,6 +41,7 @@ class ModuleType: logo_props: Optional[JSON] total_issues: int completed_issues: int + lead: Optional[UserType] @strawberry.field def project(self) -> int: @@ -71,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/workspace.py b/apiserver/plane/graphql/types/workspace.py index 62019a5189..e9cab264db 100644 --- a/apiserver/plane/graphql/types/workspace.py +++ b/apiserver/plane/graphql/types/workspace.py @@ -33,10 +33,6 @@ 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 From 2a8b77dd847d2685119af539446bb5c66fe94d82 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Mon, 26 Aug 2024 15:18:17 +0530 Subject: [PATCH 17/22] fix: on estimate switch when there is an error in fields the loader still persists (#936) --- web/ee/components/estimates/points/switch/root.tsx | 3 +++ 1 file changed, 3 insertions(+) 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({ From 803992cc98777f0be02b317769d07900bcb57fa9 Mon Sep 17 00:00:00 2001 From: Akshita Goyal <36129505+gakshita@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:56:21 +0530 Subject: [PATCH 18/22] [WEB-1936] fix: flicker issue in issues list layout (#5412) * fix: flicker issue in issues list layout * fix: formatting * fix: optimization * fix: added optional chaining for safety --- web/core/components/core/render-if-visible-HOC.tsx | 4 +++- .../issues/issue-layouts/list/block-root.tsx | 8 ++++++-- web/core/components/issues/issue-layouts/utils.tsx | 14 +++++++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) 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/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/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; +}; From 0dce67b149b1a517d6e4f3cc90859e6592916149 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:57:01 +0530 Subject: [PATCH 19/22] fix to use the correct created by while checking if the current user is the creator of the inbox issue (#5422) --- web/core/components/inbox/content/inbox-issue-header.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/core/components/inbox/content/inbox-issue-header.tsx b/web/core/components/inbox/content/inbox-issue-header.tsx index f62de095c4..6384b4e9b8 100644 --- a/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/web/core/components/inbox/content/inbox-issue-header.tsx @@ -85,8 +85,7 @@ export const InboxIssueActionsHeader: FC = observer((p const canMarkAsDeclined = isAllowed && (inboxIssue?.status === 0 || inboxIssue?.status === -2); // can delete only if admin or is creator of the issue const canDelete = - (!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) || - inboxIssue?.created_by === currentUser?.id; + (!!currentProjectRole && currentProjectRole >= EUserProjectRoles.ADMIN) || issue?.created_by === currentUser?.id; const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined; // days left for snooze const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till); From 4689ebe2ba2cf58d914d4b9c7d1d907b7e1fbb46 Mon Sep 17 00:00:00 2001 From: Ketan Sharma Date: Mon, 26 Aug 2024 16:58:32 +0530 Subject: [PATCH 20/22] Fix: Error Toast Message for Issue Attachment (#5424) --- .../attachment/attachment-item-list.tsx | 57 ++++++++++++----- .../attachments/quick-action-button.tsx | 64 +++++++++++++------ 2 files changed, 84 insertions(+), 37 deletions(-) diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx index 75881e2712..a0126b2512 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -1,8 +1,9 @@ import { FC, useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { useDropzone } from "react-dropzone"; +import { FileRejection, useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; // hooks +import {TOAST_TYPE, setToast } from "@plane/ui"; import { MAX_FILE_SIZE } from "@/constants/common"; import { generateFileName } from "@/helpers/attachment.helper"; import { useInstance, useIssueDetail } from "@/hooks/store"; @@ -36,24 +37,46 @@ export const IssueAttachmentItemList: FC = observer((p const issueAttachments = getAttachmentsByIssueId(issueId); const onDrop = useCallback( - (acceptedFiles: File[]) => { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; + (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { + const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, + if(rejectedFiles.length===0){ + const currentFile: File = acceptedFiles[0]; + if (!currentFile || !workspaceSlug) return; + + const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { + type: currentFile.type, + }); + const formData = new FormData(); + formData.append("asset", uploadedFile); + formData.append( + "attributes", + JSON.stringify({ + name: uploadedFile.name, + size: uploadedFile.size, + }) + ); + setIsLoading(true); + handleAttachmentOperations.create(formData) + .catch(()=>{ + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }) }) - ); - setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + .finally(() => setIsLoading(false)); + return; + } + + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: (totalAttachedFiles>1)? + "Only one file can be uploaded at a time." : + "File must be 5MB or less.", + }) + return; }, [handleAttachmentOperations, workspaceSlug] ); diff --git a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index 2d7a5de86f..01923b2106 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -1,8 +1,9 @@ "use client"; import React, { FC, useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { useDropzone } from "react-dropzone"; +import { FileRejection, useDropzone } from "react-dropzone"; import { Plus } from "lucide-react"; +import {TOAST_TYPE, setToast } from "@plane/ui"; // constants import { MAX_FILE_SIZE } from "@/constants/common"; // helper @@ -33,31 +34,54 @@ export const IssueAttachmentActionButton: FC = observer((props) => { // handlers const onDrop = useCallback( - (acceptedFiles: File[]) => { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; + (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { + const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, + if(rejectedFiles.length===0){ + const currentFile: File = acceptedFiles[0]; + if (!currentFile || !workspaceSlug) return; + + const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { + type: currentFile.type, + }); + const formData = new FormData(); + formData.append("asset", uploadedFile); + formData.append( + "attributes", + JSON.stringify({ + name: uploadedFile.name, + size: uploadedFile.size, + }) + ); + setIsLoading(true); + handleAttachmentOperations.create(formData) + .catch(()=>{ + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }) }) - ); - setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => { - setLastWidgetAction("attachments"); - setIsLoading(false); + .finally(() => { + setLastWidgetAction("attachments"); + setIsLoading(false); }); + return; + } + + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: (totalAttachedFiles>1)? + "Only one file can be uploaded at a time." : + "File must be 5MB or less.", + }) + return; }, [handleAttachmentOperations, workspaceSlug] ); + const { getRootProps, getInputProps } = useDropzone({ onDrop, maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, @@ -71,4 +95,4 @@ export const IssueAttachmentActionButton: FC = observer((props) => { {customButton ? customButton : } ); -}); +}); \ No newline at end of file From 762eb74ace6eb8d63cbc1d079a81ae8873ef5b15 Mon Sep 17 00:00:00 2001 From: gurusainath Date: Tue, 27 Aug 2024 12:56:34 +0530 Subject: [PATCH 21/22] ui: resolved z-index issue on module and cycle detial overview --- .../(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx | 2 +- .../(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx | 4 ++-- web/app/layout.tsx | 2 +- web/core/components/cycles/analytics-sidebar/root.tsx | 4 ++-- web/core/components/modules/analytics-sidebar/root.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) 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/layout.tsx b/web/app/layout.tsx index 3b1be761c0..cab4b8b284 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -82,7 +82,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* free trial banner */} -
{children}
+
{children}
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) => { <>
)}