diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 853d854b3..6e38537f0 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -20,7 +20,15 @@ class PageSerializer(BaseSerializer): write_only=True, required=False, ) - project = serializers.UUIDField(read_only=True) + # Many to many + label_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) + project_ids = serializers.ListField( + child=serializers.UUIDField(), + required=False, + ) class Meta: model = Page @@ -42,18 +50,14 @@ class PageSerializer(BaseSerializer): "updated_by", "view_props", "logo_props", - "project", + "label_ids", + "project_ids", ] read_only_fields = [ "workspace", "owned_by", ] - def to_representation(self, instance): - data = super().to_representation(instance) - data["labels"] = [str(label.id) for label in instance.labels.all()] - return data - def create(self, validated_data): labels = validated_data.pop("labels", None) project_id = self.context["project_id"] @@ -63,6 +67,7 @@ class PageSerializer(BaseSerializer): # Get the workspace id from the project project = Project.objects.get(pk=project_id) + # Create the page page = Page.objects.create( **validated_data, description_html=description_html, @@ -70,6 +75,7 @@ class PageSerializer(BaseSerializer): workspace_id=project.workspace_id, ) + # Create the project page ProjectPage.objects.create( workspace_id=page.workspace_id, project_id=project_id, @@ -78,6 +84,7 @@ class PageSerializer(BaseSerializer): updated_by_id=page.updated_by_id, ) + # Create page labels if labels is not None: PageLabel.objects.bulk_create( [ diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 580901879..0f2104187 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -180,7 +180,8 @@ from .page.base import ( PagesDescriptionViewSet, ) -from .search import GlobalSearchEndpoint, IssueSearchEndpoint +from .search.base import GlobalSearchEndpoint +from .search.issue import IssueSearchEndpoint from .external.base import ( diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index dbe85f2bc..9db2a4cf0 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -6,10 +6,13 @@ from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection -from django.db.models import Exists, OuterRef, Q, Subquery +from django.db.models import Exists, OuterRef, Q, Value, UUIDField from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.http import StreamingHttpResponse +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status @@ -70,9 +73,6 @@ class PageViewSet(BaseViewSet): entity_identifier=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) - project_subquery = ProjectPage.objects.filter( - page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") - ).values_list("project_id", flat=True)[:1] return self.filter_queryset( super() .get_queryset() @@ -91,8 +91,33 @@ class PageViewSet(BaseViewSet): .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") .order_by("-is_favorite", "-created_at") - .annotate(project=Subquery(project_subquery)) - .filter(project=self.kwargs.get("project_id")) + .annotate( + project=Exists( + ProjectPage.objects.filter( + page_id=OuterRef("id"), + project_id=self.kwargs.get("project_id"), + ) + ) + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .filter(project=True) .distinct() ) @@ -112,7 +137,7 @@ class PageViewSet(BaseViewSet): serializer.save() # capture the page transaction page_transaction.delay(request.data, None, serializer.data["id"]) - page = Page.objects.get(pk=serializer.data["id"]) + page = self.get_queryset().get(pk=serializer.data["id"]) serializer = PageDetailSerializer(page) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -302,7 +327,7 @@ class PageViewSet(BaseViewSet): # remove parent from all the children _ = Page.objects.filter( - parent_id=pk, project_id=project_id, workspace__slug=slug + parent_id=pk, projects__id=project_id, workspace__slug=slug ).update(parent=None) page.delete() diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search/base.py similarity index 68% rename from apiserver/plane/app/views/search.py rename to apiserver/plane/app/views/search/base.py index 15b05e83f..8a7b9d908 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search/base.py @@ -2,14 +2,17 @@ import re # Django imports -from django.db.models import Q +from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status from rest_framework.response import Response # Module imports -from .base import BaseAPIView +from plane.app.views.base import BaseAPIView from plane.db.models import ( Workspace, Project, @@ -18,8 +21,8 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectPage, ) -from plane.utils.issue_search import search_issues class GlobalSearchEndpoint(BaseAPIView): @@ -145,22 +148,51 @@ class GlobalSearchEndpoint(BaseAPIView): for field in fields: q |= Q(**{f"{field}__icontains": query}) - pages = Page.objects.filter( - q, - projects__project_projectmember__member=self.request.user, - projects__project_projectmember__is_active=True, - projects__archived_at__isnull=True, - workspace__slug=slug, + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + workspace__slug=slug, + ) + .annotate( + project_ids=Coalesce( + ArrayAgg( + "projects__id", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .annotate( + project_identifiers=Coalesce( + ArrayAgg( + "projects__identifier", + distinct=True, + filter=~Q(projects__id=True), + ), + Value([], output_field=ArrayField(CharField())), + ), + ) ) if workspace_search == "false" and project_id: - pages = pages.filter(project_id=project_id) + project_subquery = ProjectPage.objects.filter( + page_id=OuterRef("id"), + project_id=project_id, + ).values_list("project_id", flat=True)[:1] + + pages = pages.annotate( + project_id=Subquery(project_subquery) + ).filter(project_id=project_id) return pages.distinct().values( "name", "id", - "project_id", - "project__identifier", + "project_ids", + "project_identifiers", "workspace__slug", ) @@ -228,76 +260,3 @@ class GlobalSearchEndpoint(BaseAPIView): func = MODELS_MAPPER.get(model, None) results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) - - -class IssueSearchEndpoint(BaseAPIView): - def get(self, request, slug, project_id): - query = request.query_params.get("search", False) - workspace_search = request.query_params.get( - "workspace_search", "false" - ) - parent = request.query_params.get("parent", "false") - issue_relation = request.query_params.get("issue_relation", "false") - cycle = request.query_params.get("cycle", "false") - module = request.query_params.get("module", False) - sub_issue = request.query_params.get("sub_issue", "false") - target_date = request.query_params.get("target_date", True) - - issue_id = request.query_params.get("issue_id", False) - - issues = Issue.issue_objects.filter( - workspace__slug=slug, - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__archived_at__isnull=True, - ) - - if workspace_search == "false": - issues = issues.filter(project_id=project_id) - - if query: - issues = search_issues(query, issues) - - if parent == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter( - ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) - ) - if issue_relation == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter( - ~Q(pk=issue_id), - ~Q(issue_related__issue=issue), - ~Q(issue_relation__related_issue=issue), - ) - if sub_issue == "true" and issue_id: - issue = Issue.issue_objects.get(pk=issue_id) - issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) - if issue.parent: - issues = issues.filter(~Q(pk=issue.parent_id)) - - if cycle == "true": - issues = issues.exclude(issue_cycle__isnull=False) - - if module: - issues = issues.exclude(issue_module__module=module) - - if target_date == "none": - issues = issues.filter(target_date__isnull=True) - - return Response( - issues.values( - "name", - "id", - "start_date", - "sequence_id", - "project__name", - "project__identifier", - "project_id", - "workspace__slug", - "state__name", - "state__group", - "state__color", - ), - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/app/views/search/issue.py b/apiserver/plane/app/views/search/issue.py new file mode 100644 index 000000000..50b468715 --- /dev/null +++ b/apiserver/plane/app/views/search/issue.py @@ -0,0 +1,95 @@ +# Python imports +import re + +# Django imports +from django.db.models import Q + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import ( + Workspace, + Project, + Issue, + Cycle, + Module, + Page, + IssueView, +) +from plane.utils.issue_search import search_issues + + +class IssueSearchEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + query = request.query_params.get("search", False) + workspace_search = request.query_params.get( + "workspace_search", "false" + ) + parent = request.query_params.get("parent", "false") + issue_relation = request.query_params.get("issue_relation", "false") + cycle = request.query_params.get("cycle", "false") + module = request.query_params.get("module", False) + sub_issue = request.query_params.get("sub_issue", "false") + target_date = request.query_params.get("target_date", True) + + issue_id = request.query_params.get("issue_id", False) + + issues = Issue.issue_objects.filter( + workspace__slug=slug, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + ) + + if workspace_search == "false": + issues = issues.filter(project_id=project_id) + + if query: + issues = search_issues(query, issues) + + if parent == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter( + ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) + ) + if issue_relation == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter( + ~Q(pk=issue_id), + ~Q(issue_related__issue=issue), + ~Q(issue_relation__related_issue=issue), + ) + if sub_issue == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) + if issue.parent: + issues = issues.filter(~Q(pk=issue.parent_id)) + + if cycle == "true": + issues = issues.exclude(issue_cycle__isnull=False) + + if module: + issues = issues.exclude(issue_module__module=module) + + if target_date == "none": + issues = issues.filter(target_date__isnull=True) + + return Response( + issues.values( + "name", + "id", + "start_date", + "sequence_id", + "project__name", + "project__identifier", + "project_id", + "workspace__slug", + "state__name", + "state__group", + "state__color", + ), + status=status.HTTP_200_OK, + ) diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 1c94dfc06..9119c4e51 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -11,10 +11,10 @@ export type TPage = { id: string | undefined; is_favorite: boolean; is_locked: boolean; - labels: string[] | undefined; + label_ids: string[] | undefined; name: string | undefined; owned_by: string | undefined; - project: string | undefined; + project_ids: string[] | undefined; updated_at: Date | undefined; updated_by: string | undefined; workspace: string | undefined; diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index ceaa53d02..4e40009e1 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -112,6 +112,14 @@ export interface IWorkspaceIssueSearchResult { workspace__slug: string; } +export interface IWorkspacePageSearchResult { + id: string; + name: string; + project_ids: string[]; + project__identifiers: string[]; + workspace__slug: string; +} + export interface IWorkspaceProjectSearchResult { id: string; identifier: string; @@ -127,7 +135,7 @@ export interface IWorkspaceSearchResults { cycle: IWorkspaceDefaultSearchResult[]; module: IWorkspaceDefaultSearchResult[]; issue_view: IWorkspaceDefaultSearchResult[]; - page: IWorkspaceDefaultSearchResult[]; + page: IWorkspacePageSearchResult[]; }; } diff --git a/web/core/components/command-palette/helpers.tsx b/web/core/components/command-palette/helpers.tsx index 0d986b944..c29e854fa 100644 --- a/web/core/components/command-palette/helpers.tsx +++ b/web/core/components/command-palette/helpers.tsx @@ -5,6 +5,7 @@ import { Briefcase, FileText, LayoutGrid } from "lucide-react"; import { IWorkspaceDefaultSearchResult, IWorkspaceIssueSearchResult, + IWorkspacePageSearchResult, IWorkspaceProjectSearchResult, IWorkspaceSearchResult, } from "@plane/types"; @@ -67,9 +68,9 @@ export const commandGroups: { }, page: { icon: , - itemName: (page: IWorkspaceDefaultSearchResult) => ( + itemName: (page: IWorkspacePageSearchResult) => (
- {page.project__identifier} {page.name} + {page.project__identifiers?.[0]} {page.name}
), path: (page: IWorkspaceDefaultSearchResult) => diff --git a/web/core/store/pages/page.ts b/web/core/store/pages/page.ts index 11e58029c..3417e1440 100644 --- a/web/core/store/pages/page.ts +++ b/web/core/store/pages/page.ts @@ -53,14 +53,14 @@ export class Page implements IPage { logo_props: TLogoProps | undefined; description_html: string | undefined; color: string | undefined; - labels: string[] | undefined; + label_ids: string[] | undefined; owned_by: string | undefined; access: EPageAccess | undefined; is_favorite: boolean; is_locked: boolean; archived_at: string | null | undefined; workspace: string | undefined; - project: string | undefined; + project_ids: string[] | undefined; created_by: string | undefined; updated_by: string | undefined; created_at: Date | undefined; @@ -81,14 +81,14 @@ export class Page implements IPage { this.logo_props = page?.logo_props || undefined; this.description_html = page?.description_html || undefined; this.color = page?.color || undefined; - this.labels = page?.labels || undefined; + this.label_ids = page?.label_ids || undefined; this.owned_by = page?.owned_by || undefined; this.access = page?.access || EPageAccess.PUBLIC; this.is_favorite = page?.is_favorite || false; this.is_locked = page?.is_locked || false; this.archived_at = page?.archived_at || undefined; this.workspace = page?.workspace || undefined; - this.project = page?.project || undefined; + this.project_ids = page?.project_ids || undefined; this.created_by = page?.created_by || undefined; this.updated_by = page?.updated_by || undefined; this.created_at = page?.created_at || undefined; @@ -104,14 +104,14 @@ export class Page implements IPage { logo_props: observable.ref, description_html: observable.ref, color: observable.ref, - labels: observable, + label_ids: observable, owned_by: observable.ref, access: observable.ref, is_favorite: observable.ref, is_locked: observable.ref, archived_at: observable.ref, workspace: observable.ref, - project: observable.ref, + project_ids: observable, created_by: observable.ref, updated_by: observable.ref, created_at: observable.ref, @@ -181,7 +181,7 @@ export class Page implements IPage { name: this.name, description_html: this.description_html, color: this.color, - labels: this.labels, + label_ids: this.label_ids, owned_by: this.owned_by, access: this.access, logo_props: this.logo_props, @@ -189,7 +189,7 @@ export class Page implements IPage { is_locked: this.is_locked, archived_at: this.archived_at, workspace: this.workspace, - project: this.project, + project_ids: this.project_ids, created_by: this.created_by, updated_by: this.updated_by, created_at: this.created_at, diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index b5b76091c..e78d086cb 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -95,7 +95,7 @@ export class ProjectPageStore implements IProjectPageStore { if (!projectId) return undefined; // helps to filter pages based on the pageType let pagesByType = filterPagesByPageType(pageType, Object.values(this?.data || {})); - pagesByType = pagesByType.filter((p) => p.project === projectId); + pagesByType = pagesByType.filter((p) => p.project_ids?.includes(projectId)); const pages = (pagesByType.map((page) => page.id) as string[]) || undefined; @@ -114,7 +114,7 @@ export class ProjectPageStore implements IProjectPageStore { const pagesByType = filterPagesByPageType(pageType, Object.values(this?.data || {})); let filteredPages = pagesByType.filter( (p) => - p.project === projectId && + p.project_ids?.includes(projectId) && getPageName(p.name).toLowerCase().includes(this.filters.searchQuery.toLowerCase()) && shouldFilterPage(p, this.filters.filters) );