Merge pull request #415 from makeplane/sync/ce-ee

sync: community changes
This commit is contained in:
sriram veeraghanta
2024-06-17 15:54:26 +05:30
committed by GitHub
16 changed files with 169 additions and 249 deletions

View File

@@ -169,7 +169,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
@@ -191,18 +199,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"]
@@ -211,6 +215,8 @@ 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,
@@ -218,6 +224,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,
@@ -226,6 +233,7 @@ class PageSerializer(BaseSerializer):
updated_by_id=page.updated_by_id,
)
# Create page labels
if labels is not None:
PageLabel.objects.bulk_create(
[

View File

@@ -186,6 +186,7 @@ from .search.base import (
SearchEndpoint,
)
from .search.base import GlobalSearchEndpoint
from .search.issue import IssueSearchEndpoint
from .search.workspace import (
WorkspaceSearchEndpoint,

View File

@@ -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()

View File

@@ -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,7 +21,7 @@ from plane.db.models import (
Module,
Page,
IssueView,
ProjectMember,
ProjectPage,
)
@@ -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,201 +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 SearchEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
query = request.query_params.get("query", False)
query_type = request.query_params.get("query_type", "issue")
count = int(request.query_params.get("count", 5))
if query_type == "mention":
fields = ["member__first_name", "member__last_name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
users = (
ProjectMember.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
)
.order_by("-created_at")
.values(
"member__first_name",
"member__last_name",
"member__avatar",
"member__display_name",
"member__id",
)[:count]
)
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.values("name", "id")[:count]
)
return Response(
{"users": users, "pages": pages}, status=status.HTTP_200_OK
)
if query_type == "project":
fields = ["name", "identifier"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")[:count]
)
return Response(projects, status=status.HTTP_200_OK)
if query_type == "issue":
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = (
Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
project_id=project_id,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"priority",
"state_id",
)[:count]
)
return Response(issues, status=status.HTTP_200_OK)
if query_type == "cycle":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = (
Cycle.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(cycles, status=status.HTTP_200_OK)
if query_type == "module":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = (
Module.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(modules, status=status.HTTP_200_OK)
if query_type == "page":
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project_id=project_id,
workspace__slug=slug,
access=0,
)
.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"project_id",
"project__identifier",
"workspace__slug",
)[:count]
)
return Response(pages, status=status.HTTP_200_OK)
return Response(
{"error": "Please provide a valid query"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -6,7 +6,7 @@ from rest_framework import status
from rest_framework.response import Response
# Module imports
from ..base import BaseAPIView
from .base import BaseAPIView
from plane.db.models import (
Issue,
)

View File

@@ -147,7 +147,7 @@ services:
plane-redis:
<<: *app-env
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
pull_policy: if_not_present
restart: unless-stopped
volumes:

View File

@@ -9,7 +9,7 @@ volumes:
services:
plane-redis:
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: unless-stopped
networks:
- dev_env

View File

@@ -125,7 +125,7 @@ services:
plane-redis:
container_name: plane-redis
image: redis:7.2.4-alpine
image: valkey/valkey:7.2.5-alpine
restart: always
volumes:
- redisdata:/data

View File

@@ -12,10 +12,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;

View File

@@ -113,6 +113,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;
@@ -128,7 +136,7 @@ export interface IWorkspaceSearchResults {
cycle: IWorkspaceDefaultSearchResult[];
module: IWorkspaceDefaultSearchResult[];
issue_view: IWorkspaceDefaultSearchResult[];
page: IWorkspaceDefaultSearchResult[];
page: IWorkspacePageSearchResult[];
};
}

View File

@@ -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: <FileText className="h-3 w-3" />,
itemName: (page: IWorkspaceDefaultSearchResult) => (
itemName: (page: IWorkspacePageSearchResult) => (
<h6>
<span className="text-xs text-custom-text-300">{page.project__identifier}</span> {page.name}
<span className="text-xs text-custom-text-300">{page.project__identifiers?.[0]}</span> {page.name}
</h6>
),
path: (page: IWorkspaceDefaultSearchResult) =>

View File

@@ -146,7 +146,6 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
.finally(() => setIsRestoring(false));
};
// auth
const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;

View File

@@ -11,12 +11,13 @@ import {
Signal,
Tag,
Triangle,
UserCircle2,
Users,
XCircle,
} from "lucide-react";
// hooks
// components
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon } from "@plane/ui";
import { ContrastIcon, DiceIcon, DoubleCircleIcon, RelatedIcon, Tooltip } from "@plane/ui";
import {
DateDropdown,
EstimateDropdown,
@@ -26,6 +27,7 @@ import {
} from "@/components/dropdowns";
// ui
// helpers
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import {
IssueCycleSelect,
IssueLabel,
@@ -40,7 +42,8 @@ import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// types
import { useProjectEstimates, useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
import type { TIssueOperations } from "./root";
// icons
@@ -63,10 +66,14 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const {
issue: { getIssueById },
} = useIssueDetail();
const { getUserDetails } = useMember();
const { getStateById } = useProjectState();
const { isMobile } = usePlatformOS();
const issue = getIssueById(issueId);
if (!issue) return <></>;
const createdByDetails = getUserDetails(issue.created_by);
// derived values
const projectDetails = getProjectById(issue.project_id);
const stateDetails = getStateById(issue.state_id);
@@ -143,6 +150,21 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
/>
</div>
{createdByDetails && (
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Created by</span>
</div>
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
<ButtonAvatars showTooltip={false} userIds={createdByDetails.id} />
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
</div>
</Tooltip>
</div>
)}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<CalendarClock className="h-4 w-4 flex-shrink-0" />

View File

@@ -13,10 +13,11 @@ import {
CalendarClock,
CalendarCheck2,
Users,
UserCircle2,
} from "lucide-react";
// hooks
// ui icons
import { DiceIcon, DoubleCircleIcon, ContrastIcon, RelatedIcon } from "@plane/ui";
import { DiceIcon, DoubleCircleIcon, ContrastIcon, RelatedIcon, Tooltip } from "@plane/ui";
// components
import {
DateDropdown,
@@ -25,6 +26,7 @@ import {
MemberDropdown,
StateDropdown,
} from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import {
IssueLinkRoot,
IssueCycleSelect,
@@ -38,7 +40,8 @@ import {
import { cn } from "@/helpers/common.helper";
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
import { useIssueDetail, useProject, useProjectState } from "@/hooks/store";
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
interface IPeekOverviewProperties {
workspaceSlug: string;
@@ -56,9 +59,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
issue: { getIssueById },
} = useIssueDetail();
const { getStateById } = useProjectState();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
// derived values
const issue = getIssueById(issueId);
if (!issue) return <></>;
const createdByDetails = getUserDetails(issue?.created_by);
const projectDetails = getProjectById(issue.project_id);
const isEstimateEnabled = projectDetails?.estimate;
const stateDetails = getStateById(issue.state_id);
@@ -134,6 +140,22 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
/>
</div>
{/* created by */}
{createdByDetails && (
<div className="flex w-full items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<UserCircle2 className="h-4 w-4 flex-shrink-0" />
<span>Created by</span>
</div>
<Tooltip tooltipContent={createdByDetails?.display_name} isMobile={isMobile}>
<div className="h-full flex items-center gap-1.5 rounded px-2 py-0.5 text-sm justify-between cursor-default">
<ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />
<span className="flex-grow truncate text-xs leading-5">{createdByDetails?.display_name}</span>
</div>
</Tooltip>
</div>
)}
{/* start date */}
<div className="flex w-full items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">

View File

@@ -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,

View File

@@ -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)
);