Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 117 additions & 10 deletions apps/api/plane/app/views/view/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
Expand Down Expand Up @@ -132,6 +138,16 @@ def destroy(self, request, slug, pk):


class WorkspaceViewIssuesViewSet(BaseViewSet):
"""
ViewSet for workspace-level issue queries with optional grouped pagination.

Backward Compatibility:
- Without group_by parameter: Returns flat list of issues (same as original behavior)
- With group_by parameter: Returns grouped structure with per-group pagination
- Response fields are identical in both cases, matching the original ViewIssueListSerializer
- Follows the same pattern as IssueViewSet for project-level issues
"""

filter_backends = (ComplexFilterBackend,)
filterset_class = IssueFilterSet

Expand Down Expand Up @@ -227,9 +243,8 @@ def list(self, request, slug):
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)

# Base query for the counts
total_issue_count_queryset = copy.deepcopy(issue_queryset)
total_issue_count_queryset = total_issue_count_queryset.only("id")
# Keeping a copy of the queryset before applying annotations (for counts)
filtered_issue_queryset = copy.deepcopy(issue_queryset)

# Apply annotations to the issue queryset
issue_queryset = self.apply_annotations(issue_queryset)
Expand All @@ -239,15 +254,107 @@ def list(self, request, slug):
issue_queryset=issue_queryset, order_by_param=order_by_param
)

# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
total_count_queryset=total_issue_count_queryset,
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)

# Validate group_by and sub_group_by field names
ALLOWED_GROUP_BY_FIELDS = {
"state_id", "labels__id", "assignees__id", "issue_module__module_id",
"cycle_id", "project_id", "priority", "state__group",
"target_date", "start_date", "created_by",
}
if group_by and group_by not in ALLOWED_GROUP_BY_FIELDS:
return Response(
{"error": f"Invalid group_by field: {group_by}"},
status=status.HTTP_400_BAD_REQUEST,
)
if sub_group_by and sub_group_by not in ALLOWED_GROUP_BY_FIELDS:
return Response(
{"error": f"Invalid sub_group_by field: {sub_group_by}"},
status=status.HTTP_400_BAD_REQUEST,
)

# Apply grouper to issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
)
Comment on lines +278 to 281
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "def issue_queryset_grouper" -A 30 apps/api/plane/app/views/view/base.py

Repository: makeplane/plane

Length of output: 41


🏁 Script executed:

rg -n "def issue_queryset_grouper" -A 30

Repository: makeplane/plane

Length of output: 4567


🏁 Script executed:

rg -n "def issue_queryset_grouper" -A 100 apps/api/plane/utils/grouper.py | head -80

Repository: makeplane/plane

Length of output: 2965


🏁 Script executed:

sed -n '270,290p' apps/api/plane/app/views/view/base.py

Repository: makeplane/plane

Length of output: 879


Unconditional invocation of grouper applies unnecessary annotations when grouping is not used.

The issue_queryset_grouper function is called even when group_by and sub_group_by are both falsy. While the function safely skips filters in those cases (lines 41-43 in grouper.py), it still unconditionally applies all subquery annotations to the queryset (lines 81-86). When grouping is not needed, these annotations—particularly the assignee_ids, label_ids, and module_ids subqueries—are computed unnecessarily, adding overhead to the query. Consider adding an early return when neither grouping parameter is provided.

🤖 Prompt for AI Agents
In `@apps/api/plane/app/views/view/base.py` around lines 278 - 281, The code
always calls issue_queryset_grouper even when grouping isn't requested, causing
expensive subquery annotations (e.g., assignee_ids, label_ids, module_ids) to be
added unnecessarily; modify issue_queryset_grouper to return the original
queryset immediately when both group_by and sub_group_by are falsy (add an early
return at the top of issue_queryset_grouper) so no annotations are applied when
grouping is not used, and keep the existing behavior for the rest of the
function.


if group_by:
if sub_group_by:
if group_by == sub_group_by:
return Response(
{"error": "Group by and sub group by cannot have same parameters"},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# Sub-grouped paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=None,
filters=filters,
queryset=filtered_issue_queryset,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by,
slug=slug,
project_id=None,
filters=filters,
queryset=filtered_issue_queryset,
),
group_by_field_name=group_by,
count_filter=Q(
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List paginate (no grouping)
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
total_count_queryset=filtered_issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)


class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
Expand Down
4 changes: 4 additions & 0 deletions apps/api/plane/utils/filters/filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ class IssueFilterSet(BaseFilterSet):
subscriber_id = filters.UUIDFilter(method="filter_subscriber_id")
subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in")

# Date null filters for "none" handling
target_date__isnull = filters.BooleanFilter(field_name="target_date", lookup_expr="isnull")
start_date__isnull = filters.BooleanFilter(field_name="start_date", lookup_expr="isnull")

class Meta:
model = Issue
fields = {
Expand Down
2 changes: 1 addition & 1 deletion apps/live/src/services/page/core.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
description_binary: string;
description_html: string;
description: object;
description_json: object;
};

export type TUserMention = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ export const GlobalIssuesHeader = observer(function GlobalIssuesHeader() {
<GlobalViewLayoutSelection
onChange={handleLayoutChange}
selectedLayout={activeLayout ?? EIssueLayoutTypes.SPREADSHEET}
workspaceSlug={workspaceSlug.toString()}
/>
)}
{globalViewId && <WorkItemFiltersToggle entityType={EIssuesStoreType.GLOBAL} entityId={globalViewId} />}
Expand Down
47 changes: 44 additions & 3 deletions apps/web/ce/components/views/helper.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,59 @@
import type { EIssueLayoutTypes, IProjectView } from "@plane/types";
import { EIssueLayoutTypes as LayoutTypes } from "@plane/types";
import type { TWorkspaceLayoutProps } from "@/components/views/helper";
import { LayoutSelection } from "@/components/issues/issue-layouts/filters/header/layout-selection";
import { WorkspaceCalendarRoot } from "@/components/issues/issue-layouts/calendar/roots/workspace-root";
import { WorkspaceKanBanRoot } from "@/components/issues/issue-layouts/kanban/roots/workspace-root";

export type TLayoutSelectionProps = {
onChange: (layout: EIssueLayoutTypes) => void;
selectedLayout: EIssueLayoutTypes;
workspaceSlug: string;
};

// Supported layouts for workspace views: Spreadsheet, Calendar, Kanban
const WORKSPACE_VIEW_LAYOUTS: EIssueLayoutTypes[] = [
LayoutTypes.SPREADSHEET,
LayoutTypes.CALENDAR,
LayoutTypes.KANBAN,
];

export function GlobalViewLayoutSelection(props: TLayoutSelectionProps) {
return <></>;
const { onChange, selectedLayout } = props;

return (
<LayoutSelection
layouts={WORKSPACE_VIEW_LAYOUTS}
onChange={onChange}
selectedLayout={selectedLayout}
/>
);
}

export function WorkspaceAdditionalLayouts(props: TWorkspaceLayoutProps) {
return <></>;
const {
activeLayout,
isDefaultView,
globalViewId,
} = props;

switch (activeLayout) {
case LayoutTypes.CALENDAR:
return (
<WorkspaceCalendarRoot
isDefaultView={isDefaultView}
globalViewId={globalViewId}
/>
);
case LayoutTypes.KANBAN:
return (
<WorkspaceKanBanRoot
isDefaultView={isDefaultView}
globalViewId={globalViewId}
/>
);
default:
return null;
}
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// ui
import { Spinner } from "@plane/ui";
import { ChevronRight } from "lucide-react";
import { renderFormattedPayloadDate, cn } from "@plane/utils";
// constants
import { MONTHS_LIST } from "@/constants/calendar";
Expand All @@ -24,12 +25,8 @@ import { MONTHS_LIST } from "@/constants/calendar";
import { useIssues } from "@/hooks/store/use-issues";
import useSize from "@/hooks/use-window-size";
// store
import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic";
import type { ICycleIssuesFilter } from "@/store/issue/cycle";
import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store";
import type { ICalendarStore } from "@/store/issue/issue_calendar_view.store";
import type { IModuleIssuesFilter } from "@/store/issue/module";
import type { IProjectIssuesFilter } from "@/store/issue/project";
import type { IProjectViewIssuesFilter } from "@/store/issue/project-views";
// local imports
import { IssueLayoutHOC } from "../issue-layout-HOC";
import type { TRenderQuickActions } from "../list/list-view-types";
Expand All @@ -39,7 +36,7 @@ import { CalendarWeekDays } from "./week-days";
import { CalendarWeekHeader } from "./week-header";

type Props = {
issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter;
issuesFilterStore: IBaseIssueFilterStore;
issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues;
layout: "month" | "week" | undefined;
Expand All @@ -56,7 +53,7 @@ type Props = {
sourceDate: string | undefined,
destinationDate: string | undefined
) => Promise<void>;
addIssuesToView?: (issueIds: string[]) => Promise<any>;
addIssuesToView?: (issueIds: string[]) => Promise<unknown>;
readOnly?: boolean;
updateFilters?: (
projectId: string,
Expand All @@ -65,6 +62,12 @@ type Props = {
) => Promise<void>;
canEditProperties: (projectId: string | undefined) => boolean;
isEpic?: boolean;
// Optional overrides for quick add - when not provided, uses store's viewFlags
enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean;
// "No Date" section props (for workspace views)
noDateIssueIds?: string[];
noDateIssueCount?: number;
};

export const CalendarChart = observer(function CalendarChart(props: Props) {
Expand All @@ -86,9 +89,14 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {
canEditProperties,
readOnly = false,
isEpic = false,
enableQuickIssueCreate: enableQuickIssueCreateProp,
disableIssueCreation: disableIssueCreationProp,
noDateIssueIds,
noDateIssueCount,
} = props;
// states
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [isNoDateCollapsed, setIsNoDateCollapsed] = useState(false);
//refs
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
// store hooks
Expand All @@ -98,7 +106,10 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {

const [windowWidth] = useSize();

const { enableIssueCreation, enableQuickAdd } = viewFlags || {};
// Use props if provided, otherwise fall back to store's viewFlags
const enableQuickAdd = enableQuickIssueCreateProp ?? viewFlags?.enableQuickAdd ?? false;
const enableIssueCreation =
disableIssueCreationProp !== undefined ? !disableIssueCreationProp : (viewFlags?.enableIssueCreation ?? true);

const calendarPayload = issueCalendarView.calendarPayload;

Expand All @@ -107,6 +118,9 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {
const formattedDatePayload = renderFormattedPayloadDate(selectedDate) ?? undefined;

// Enable Auto Scroll for calendar
// Note: Empty dependency array is intentional - refs are populated before effects run,
// so scrollableContainerRef.current is available on mount. React doesn't track ref.current
// changes, so including it in deps wouldn't cause re-runs anyway.
useEffect(() => {
const element = scrollableContainerRef.current;

Expand All @@ -117,7 +131,7 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {
element,
})
);
}, [scrollableContainerRef?.current]);
}, []);

if (!calendarPayload || !formattedDatePayload)
return (
Expand Down Expand Up @@ -223,6 +237,45 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {
isEpic={isEpic}
/>
</div>

{/* No Date section - for workspace views */}
{noDateIssueIds && noDateIssueIds.length > 0 && (
<div className="hidden md:block border-t border-subtle-1">
<button
type="button"
className="flex w-full items-center gap-2 px-4 py-2 bg-layer-1 cursor-pointer hover:bg-layer-2 text-left"
onClick={() => setIsNoDateCollapsed(!isNoDateCollapsed)}
>
<ChevronRight
className={cn("size-4 text-tertiary transition-transform", {
"rotate-90": !isNoDateCollapsed,
})}
/>
<span className="text-13 font-medium text-secondary">No Date</span>
<span className="text-11 text-tertiary">({noDateIssueCount ?? noDateIssueIds.length})</span>
</button>
{!isNoDateCollapsed && (
<div className="px-4 py-2 bg-surface-1">
<CalendarIssueBlocks
date={new Date()}
issueIdList={noDateIssueIds}
loadMoreIssues={() => {}}
getPaginationData={() => undefined}
getGroupIssueCount={() => noDateIssueCount}
quickActions={quickActions}
enableQuickIssueCreate={false}
disableIssueCreation={true}
quickAddCallback={quickAddCallback}
addIssuesToView={addIssuesToView}
readOnly={readOnly}
canEditProperties={canEditProperties}
isDragDisabled
isEpic={isEpic}
/>
</div>
)}
</div>
)}
</div>
</IssueLayoutHOC>

Expand Down
Loading