Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cda1db0
Implementation of the feature #6642 Calendar/Kanban View for Individu…
anton-v-a Jan 27, 2026
a0f7cb9
fix(workspace-views): Add viewId param to fetchNextIssues for consist…
anton-v-a Jan 31, 2026
ab68722
fix(workspace-calendar): Remove incorrect type annotation for getView…
anton-v-a Jan 31, 2026
3a8c9d4
fix: re-check sub_group_by after normalizing group_by in workspace ka…
anton-v-a Jan 31, 2026
6781620
fix: correct useEffect dependencies in CalendarDayTile
anton-v-a Jan 31, 2026
e161b0d
fix: remove unused workspaceSlug from TLayoutSelectionProps
anton-v-a Jan 31, 2026
163d1c0
fix: validate group_by and sub_group_by fields in workspace view
anton-v-a Jan 31, 2026
e21bcee
fix: compute canCreateIssues once with useMemo instead of per call
anton-v-a Jan 31, 2026
55361e1
fix: use API total_count for no-date issues and increase page size
anton-v-a Jan 31, 2026
4bbd88d
fix: let delete failures propagate to DeleteIssueModal
anton-v-a Jan 31, 2026
e90d7fc
fix: avoid mutating store-backed collapsedGroups array
anton-v-a Jan 31, 2026
10a85c9
fix: always strip state_detail.group from quick-add payload
anton-v-a Jan 31, 2026
c77db06
fix: guard against missing project context in state-group DnD
anton-v-a Jan 31, 2026
22591c7
fix: use console.error with typed error in workspace filter store
anton-v-a Feb 15, 2026
58f6240
perf: skip expensive subquery annotations when grouping is not requested
anton-v-a Feb 15, 2026
1d8eae6
Merge branch 'preview' into feat-workspace-kanban-calendar-view
anton-v-a Feb 15, 2026
dc26cbe
fix: handle unhandled promise rejections in workspace filter store fi…
anton-v-a Feb 15, 2026
c23cb93
refactor: extract shared kanban/calendar layout defaults into applyLa…
anton-v-a Feb 15, 2026
c55a908
fix: resolve broken import path for WorkspaceService in workspace cal…
anton-v-a Feb 15, 2026
38ea179
refactor: invert calendar condition to remove empty if branch
anton-v-a Feb 15, 2026
abd22e7
fix: skip clearing issue IDs when layout is unchanged to avoid loader…
anton-v-a Feb 15, 2026
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 @@ -42,6 +42,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 @@ -136,6 +142,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 @@ -231,9 +247,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 @@ -243,15 +258,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
)

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 @@ -157,6 +157,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
3 changes: 3 additions & 0 deletions apps/api/plane/utils/grouper.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ def issue_queryset_grouper(
group_by: Optional[str],
sub_group_by: Optional[str],
) -> QuerySet[Issue]:
if not group_by and not sub_group_by:
return queryset

FIELD_MAPPER: Dict[str, str] = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,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
Expand Up @@ -5,20 +5,61 @@
*/

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 @@ -22,6 +22,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 @@ -30,12 +31,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 @@ -45,7 +42,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 @@ -62,7 +59,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 @@ -71,6 +68,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 @@ -92,9 +95,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 @@ -104,7 +112,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 @@ -113,6 +124,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 @@ -123,7 +137,7 @@ export const CalendarChart = observer(function CalendarChart(props: Props) {
element,
})
);
}, [scrollableContainerRef?.current]);
}, []);

if (!calendarPayload || !formattedDatePayload)
return (
Expand Down Expand Up @@ -229,6 +243,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