From cda1db0891da0ed30440ba7d4d7ec7bca5f27bb9 Mon Sep 17 00:00:00 2001 From: Anton <35825286+anton-v-a@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:27:17 -0500 Subject: [PATCH 01/20] Implementation of the feature #6642 Calendar/Kanban View for Individual User across the Projects --- apps/api/plane/app/views/view/base.py | 110 ++++++- apps/api/plane/utils/filters/filterset.py | 4 + apps/live/src/services/page/core.service.ts | 2 +- apps/web/ce/components/views/helper.tsx | 46 ++- .../issue-layouts/calendar/calendar.tsx | 71 ++++- .../issue-layouts/calendar/day-tile.tsx | 8 +- .../calendar/dropdowns/months-dropdown.tsx | 8 +- .../calendar/dropdowns/options-dropdown.tsx | 8 +- .../issues/issue-layouts/calendar/header.tsx | 8 +- .../calendar/roots/workspace-root.tsx | 287 +++++++++++++++++ .../issue-layouts/calendar/week-days.tsx | 8 +- .../issues/issue-layouts/issue-layout-HOC.tsx | 3 +- .../kanban/roots/workspace-root.tsx | 289 ++++++++++++++++++ .../issues/issue-layouts/quick-add/index.ts | 1 + .../issues/issue-layouts/quick-add/root.tsx | 21 +- .../quick-add/workspace-root.tsx | 216 +++++++++++++ .../roots/all-issue-layout-root.tsx | 25 +- .../components/issues/issue-layouts/utils.tsx | 115 +++++-- apps/web/core/hooks/store/use-issues.ts | 4 +- apps/web/core/hooks/use-group-dragndrop.ts | 3 +- apps/web/core/hooks/use-issues-actions.tsx | 32 +- apps/web/core/services/workspace.service.ts | 8 +- .../store/issue/helpers/base-issues.store.ts | 14 + .../store/issue/workspace/filter.store.ts | 76 ++++- .../core/store/issue/workspace/issue.store.ts | 48 ++- packages/constants/src/issue/common.ts | 1 + packages/constants/src/issue/filter.ts | 30 +- 27 files changed, 1328 insertions(+), 118 deletions(-) create mode 100644 apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx create mode 100644 apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx create mode 100644 apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 98fe04c62fa..64d0760be2e 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -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 @@ -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 @@ -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) @@ -239,15 +254,90 @@ 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) + + # 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 diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py index 0099b83d099..afd87dacd03 100644 --- a/apps/api/plane/utils/filters/filterset.py +++ b/apps/api/plane/utils/filters/filterset.py @@ -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 = { diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts index 235dc04416b..ca1c2065ffa 100644 --- a/apps/live/src/services/page/core.service.ts +++ b/apps/live/src/services/page/core.service.ts @@ -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 = { diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx index 155249e2d17..9335cc78bce 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -1,5 +1,9 @@ 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; @@ -7,12 +11,50 @@ export type TLayoutSelectionProps = { 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 ( + + ); } export function WorkspaceAdditionalLayouts(props: TWorkspaceLayoutProps) { - return <>; + const { + activeLayout, + isDefaultView, + globalViewId, + } = props; + + switch (activeLayout) { + case LayoutTypes.CALENDAR: + return ( + + ); + case LayoutTypes.KANBAN: + return ( + + ); + default: + return null; + } } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx index b6569300f49..a0e9c7ddcf6 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx @@ -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"; @@ -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"; @@ -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; @@ -56,7 +53,7 @@ type Props = { sourceDate: string | undefined, destinationDate: string | undefined ) => Promise; - addIssuesToView?: (issueIds: string[]) => Promise; + addIssuesToView?: (issueIds: string[]) => Promise; readOnly?: boolean; updateFilters?: ( projectId: string, @@ -65,6 +62,12 @@ type Props = { ) => Promise; 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) { @@ -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(new Date()); + const [isNoDateCollapsed, setIsNoDateCollapsed] = useState(false); //refs const scrollableContainerRef = useRef(null); // store hooks @@ -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; @@ -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; @@ -117,7 +131,7 @@ export const CalendarChart = observer(function CalendarChart(props: Props) { element, }) ); - }, [scrollableContainerRef?.current]); + }, []); if (!calendarPayload || !formattedDatePayload) return ( @@ -223,6 +237,45 @@ export const CalendarChart = observer(function CalendarChart(props: Props) { isEpic={isEpic} /> + + {/* No Date section - for workspace views */} + {noDateIssueIds && noDateIssueIds.length > 0 && ( +
+ + {!isNoDateCollapsed && ( +
+ {}} + getPaginationData={() => undefined} + getGroupIssueCount={() => noDateIssueCount} + quickActions={quickActions} + enableQuickIssueCreate={false} + disableIssueCreation={true} + quickAddCallback={quickAddCallback} + addIssuesToView={addIssuesToView} + readOnly={readOnly} + canEditProperties={canEditProperties} + isDragDisabled + isEpic={isEpic} + /> +
+ )} +
+ )} diff --git a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx index 9c92945eb48..a174c99f18e 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx @@ -14,16 +14,12 @@ import { highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils"; import { MONTHS_LIST } from "@/constants/calendar"; // helpers // types -import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; -import type { ICycleIssuesFilter } from "@/store/issue/cycle"; -import type { IModuleIssuesFilter } from "@/store/issue/module"; -import type { IProjectIssuesFilter } from "@/store/issue/project"; -import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store"; import type { TRenderQuickActions } from "../list/list-view-types"; import { CalendarIssueBlocks } from "./issue-blocks"; type Props = { - issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issuesFilterStore: IBaseIssueFilterStore; date: ICalendarDate; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx index dff887c26f0..d150fac609d 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/months-dropdown.tsx @@ -9,15 +9,11 @@ import { ChevronLeftIcon, ChevronRightIcon } from "@plane/propel/icons"; import { getDate } from "@plane/utils"; import { MONTHS_LIST } from "@/constants/calendar"; import { useCalendarView } from "@/hooks/store/use-calendar-view"; -import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; -import type { ICycleIssuesFilter } from "@/store/issue/cycle"; -import type { IModuleIssuesFilter } from "@/store/issue/module"; -import type { IProjectIssuesFilter } from "@/store/issue/project"; -import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store"; // helpers interface Props { - issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issuesFilterStore: IBaseIssueFilterStore; } export const CalendarMonthsDropdown = observer(function CalendarMonthsDropdown(props: Props) { const { issuesFilterStore } = props; diff --git a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx index a2c30768388..cc798b24c4c 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/dropdowns/options-dropdown.tsx @@ -18,14 +18,10 @@ import { ToggleSwitch } from "@plane/ui"; import { CALENDAR_LAYOUTS } from "@/constants/calendar"; import { useCalendarView } from "@/hooks/store/use-calendar-view"; import useSize from "@/hooks/use-window-size"; -import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; -import type { ICycleIssuesFilter } from "@/store/issue/cycle"; -import type { IModuleIssuesFilter } from "@/store/issue/module"; -import type { IProjectIssuesFilter } from "@/store/issue/project"; -import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store"; interface ICalendarHeader { - issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issuesFilterStore: IBaseIssueFilterStore; updateFilters?: ( projectId: string, filterType: TSupportedFilterTypeForUpdate, diff --git a/apps/web/core/components/issues/issue-layouts/calendar/header.tsx b/apps/web/core/components/issues/issue-layouts/calendar/header.tsx index 6f653963dd3..3617e7c1525 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/header.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/header.tsx @@ -8,15 +8,11 @@ import type { TSupportedFilterForUpdate } from "@plane/types"; import { Row } from "@plane/ui"; // icons import { useCalendarView } from "@/hooks/store/use-calendar-view"; -import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; -import type { ICycleIssuesFilter } from "@/store/issue/cycle"; -import type { IModuleIssuesFilter } from "@/store/issue/module"; -import type { IProjectIssuesFilter } from "@/store/issue/project"; -import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store"; import { CalendarMonthsDropdown, CalendarOptionsDropdown } from "./dropdowns"; interface ICalendarHeader { - issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issuesFilterStore: IBaseIssueFilterStore; updateFilters?: ( projectId: string, filterType: TSupportedFilterTypeForUpdate, diff --git a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx new file mode 100644 index 00000000000..b09e6771c42 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -0,0 +1,287 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueGroupByToServerOptions, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TGroupedIssues, TIssue, TIssuesResponse } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +// components +import { AllIssueQuickActions } from "../../quick-action-dropdowns"; +// hooks +import { useCalendarView } from "@/hooks/store/use-calendar-view"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; +// services +import { WorkspaceService } from "@/plane-web/services"; +// local imports +import { CalendarChart } from "../calendar"; +import { handleDragDrop } from "../utils"; + +const workspaceService = new WorkspaceService(); + +type Props = { + isDefaultView: boolean; + globalViewId: string; +}; + +export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(props: Props) { + const { globalViewId } = props; + + // router + const { workspaceSlug } = useParams(); + + // state for "No Date" issues + const [noDateIssueIds, setNoDateIssueIds] = useState([]); + const [noDateTotalCount, setNoDateTotalCount] = useState(0); + + // hooks + const { allowPermissions } = useUserPermissions(); + const { issues, issuesFilter, issueMap, addIssuesToMap } = useIssues(EIssuesStoreType.GLOBAL); + const { fetchIssues, fetchNextIssues, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters } = + useIssuesActions(EIssuesStoreType.GLOBAL); + const { joinedProjectIds } = useProject(); + + const issueCalendarView = useCalendarView(); + + const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + + // Check if user can create issues in at least one project + const canCreateIssues = useCallback(() => { + if (!joinedProjectIds || joinedProjectIds.length === 0) return false; + return joinedProjectIds.some((projectId) => + allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ) + ); + }, [joinedProjectIds, allowPermissions, workspaceSlug]); + + // Quick add callback that wraps the quickAddIssue action + const handleQuickAddIssue = useCallback( + async (projectId: string | null | undefined, data: TIssue) => { + if (!projectId || !quickAddIssue) return; + return await quickAddIssue(projectId, data); + }, + [quickAddIssue] + ); + + const displayFilters = issuesFilter.issueFilters?.displayFilters; + + const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues; + + const layout = displayFilters?.calendar?.layout ?? "month"; + const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {}; + + // Memoize applied filters to use as a stable dependency for No Date fetching + // This prevents re-fetching when unrelated filter store properties change + const appliedFilters = issuesFilter.getAppliedFilters(globalViewId); + const appliedFiltersKey = useMemo(() => JSON.stringify(appliedFilters ?? {}), [appliedFilters]); + + // Fetch issues on mount and when date range changes + // Fire-and-forget: MobX store updates trigger re-renders when data arrives + useEffect(() => { + if (startDate && endDate && layout && workspaceSlug && globalViewId) { + void fetchIssues( + "init-loader", + { + canGroup: true, + perPageCount: layout === "month" ? 4 : 30, + before: endDate, + after: startDate, + groupedBy: EIssueGroupByToServerOptions["target_date"], + }, + globalViewId + ); + } + }, [fetchIssues, workspaceSlug, startDate, endDate, layout, globalViewId]); + + // Fetch "No Date" issues (issues without target_date) separately from date-range issues. + // Architecture note: This makes a separate API call from the main calendar fetch. + // This is intentional because: + // 1. Date-range issues need grouping by target_date, no-date issues don't + // 2. Separate calls allow independent pagination and loading states + // 3. Results are cached in local state and only re-fetched when filters change + // If performance becomes a concern, consider batching into a single API call. + // Fire-and-forget: local state updates when fetch completes + useEffect(() => { + if (!workspaceSlug || !globalViewId) return; + + const fetchNoDateIssues = async () => { + try { + // Get base params from the filter store for the current view + const baseParams = issuesFilter.getFilterParams( + { canGroup: false, perPageCount: 50 }, + globalViewId, + undefined, + undefined, + undefined + ); + // Remove any existing target_date filter to avoid conflicts with target_date__isnull + // The view might have date range filters that would otherwise override our null filter + const { target_date: _existingTargetDate, ...paramsWithoutTargetDate } = baseParams as Record; + // Add filter for issues without target_date + const params = { + ...paramsWithoutTargetDate, + target_date__isnull: "true", + }; + + const response: TIssuesResponse = await workspaceService.getViewIssues(workspaceSlug.toString(), params); + + if (response && response.results) { + const results = response.results; + if (Array.isArray(results)) { + // Type guard to extract TIssue objects and filter to only issues without target_date + // The client-side filter is defensive - API should already filter via target_date__isnull + const issues = results.filter( + (issue: TIssue | string): issue is TIssue => + typeof issue !== "string" && !!issue.id && !issue.target_date + ); + const issueIds = issues.map((issue) => issue.id); + setNoDateIssueIds(issueIds); + setNoDateTotalCount(issueIds.length); + + // Add issues to the issue map so they can be displayed + if (issues.length > 0) { + addIssuesToMap(issues); + } + } else { + setNoDateIssueIds([]); + setNoDateTotalCount(0); + } + } + } catch (error) { + console.error("Failed to fetch no-date issues:", error); + setNoDateIssueIds([]); + setNoDateTotalCount(0); + } + }; + + void fetchNoDateIssues(); + }, [workspaceSlug, globalViewId, appliedFiltersKey]); + + // Permission callback for per-project permission check + const canEditPropertiesBasedOnProject = useCallback( + (projectId: string | undefined) => { + if (!projectId) return false; + return allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ); + }, + [allowPermissions, workspaceSlug] + ); + + const canEditProperties = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = canEditPropertiesBasedOnProject(projectId); + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing] + ); + + // Drag and drop handler for changing target date + const handleDragAndDrop = async ( + issueId: string | undefined, + issueProjectId: string | undefined, + sourceDate: string | undefined, + destinationDate: string | undefined + ) => { + if (!issueId || !destinationDate || !sourceDate || !issueProjectId) return; + + // Check permission for the specific project + if (!canEditPropertiesBasedOnProject(issueProjectId)) { + setToast({ + title: "Permission denied", + type: TOAST_TYPE.ERROR, + message: "You don't have permission to edit this issue", + }); + return; + } + + await handleDragDrop( + issueId, + sourceDate, + destinationDate, + workspaceSlug?.toString(), + issueProjectId, + updateIssue + ).catch((err: { detail?: string }) => { + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: err?.detail ?? "Failed to perform this action", + }); + }); + }; + + const loadMoreIssues = useCallback( + (dateString: string) => { + void fetchNextIssues(dateString); + }, + [fetchNextIssues] + ); + + const getPaginationData = useCallback( + (groupId: string | undefined) => issues?.getPaginationData(groupId, undefined), + [issues] + ); + + const getGroupIssueCount = useCallback( + (groupId: string | undefined) => issues?.getGroupIssueCount(groupId, undefined, false), + [issues] + ); + + return ( +
+ ( + { + await removeIssue(issue.project_id, issue.id); + }} + handleUpdate={async (data) => { + if (updateIssue) await updateIssue(issue.project_id, issue.id, data); + }} + handleArchive={async () => { + if (archiveIssue) await archiveIssue(issue.project_id, issue.id); + }} + readOnly={!canEditProperties(issue.project_id ?? undefined)} + placements={placement} + /> + )} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} + // Workspace views are filter-based, not container-based like cycles/modules. + // Issues appear based on their properties, not by being explicitly added to a view. + addIssuesToView={undefined} + enableQuickIssueCreate={enableQuickAdd && canCreateIssues()} + disableIssueCreation={!enableIssueCreation || !canCreateIssues()} + quickAddCallback={handleQuickAddIssue} + readOnly={false} + updateFilters={updateFilters} + handleDragAndDrop={handleDragAndDrop} + canEditProperties={canEditProperties} + isEpic={false} + noDateIssueIds={noDateIssueIds} + noDateIssueCount={noDateTotalCount} + /> +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx b/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx index edc8716ea44..e530b6a3da6 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/week-days.tsx @@ -5,16 +5,12 @@ import { cn, getOrderedDays, renderFormattedPayloadDate } from "@plane/utils"; // hooks import { useUserProfile } from "@/hooks/store/user"; // types -import type { IProjectEpicsFilter } from "@/plane-web/store/issue/epic"; -import type { ICycleIssuesFilter } from "@/store/issue/cycle"; -import type { IModuleIssuesFilter } from "@/store/issue/module"; -import type { IProjectIssuesFilter } from "@/store/issue/project"; -import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { IBaseIssueFilterStore } from "@/store/issue/helpers/issue-filter-helper.store"; import type { TRenderQuickActions } from "../list/list-view-types"; import { CalendarDayTile } from "./day-tile"; type Props = { - issuesFilterStore: IProjectIssuesFilter | IModuleIssuesFilter | ICycleIssuesFilter | IProjectViewIssuesFilter; + issuesFilterStore: IBaseIssueFilterStore; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; diff --git a/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx b/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx index a8386048258..42674faef01 100644 --- a/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx +++ b/apps/web/core/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -43,8 +43,9 @@ export const IssueLayoutHOC = observer(function IssueLayoutHOC(props: Props) { const { issues } = useIssues(storeType); const issueCount = issues.getGroupIssueCount(undefined, undefined, false); + const loader = issues?.getIssueLoader(); - if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) { + if (loader === "init-loader" || issueCount === undefined) { return ; } diff --git a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx new file mode 100644 index 00000000000..963efe08dd3 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EIssueFilterType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import type { TIssue } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes, EIssueServiceType } from "@plane/types"; +// components +import { AllIssueQuickActions } from "../../quick-action-dropdowns"; +import { DeleteIssueModal } from "../../../delete-issue-modal"; +import { IssueLayoutHOC } from "../../issue-layout-HOC"; +import type { TRenderQuickActions } from "../../list/list-view-types"; +import { getSourceFromDropPayload } from "../../utils"; +import { KanBan } from "../default"; +import { KanBanSwimLanes } from "../swimlanes"; +// hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; +import { useIssues } from "@/hooks/store/use-issues"; +import { useKanbanView } from "@/hooks/store/use-kanban-view"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop"; +import { useIssuesActions } from "@/hooks/use-issues-actions"; + +type Props = { + isDefaultView: boolean; + globalViewId: string; +}; + +export const WorkspaceKanBanRoot = observer(function WorkspaceKanBanRoot(props: Props) { + const { globalViewId: _globalViewId } = props; + + // router + const { workspaceSlug } = useParams(); + + // store hooks + const { allowPermissions } = useUserPermissions(); + const { issueMap, issuesFilter, issues } = useIssues(EIssuesStoreType.GLOBAL); + const { + issue: { getIssueById }, + } = useIssueDetail(EIssueServiceType.ISSUES); + const { fetchNextIssues, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters } = + useIssuesActions(EIssuesStoreType.GLOBAL); + const { joinedProjectIds } = useProject(); + + const deleteAreaRef = useRef(null); + const [isDragOverDelete, setIsDragOverDelete] = useState(false); + + const { isDragging } = useKanbanView(); + + const displayFilters = issuesFilter?.issueFilters?.displayFilters; + const displayProperties = issuesFilter?.issueFilters?.displayProperties; + + const sub_group_by = displayFilters?.sub_group_by; + const group_by = displayFilters?.group_by; + const orderBy = displayFilters?.order_by; + + // Note: We don't fetch issues here - the parent component (all-issue-layout-root.tsx) handles initial fetch + // and the filter store handles fetches when layout/group_by changes. This prevents race conditions. + + const fetchMoreIssues = useCallback( + (groupId?: string, subgroupId?: string) => { + if (issues?.getIssueLoader(groupId, subgroupId) !== "pagination") { + void fetchNextIssues(groupId, subgroupId); + } + }, + [fetchNextIssues, issues] + ); + + const groupedIssueIds = issues?.groupedIssueIds; + + const userDisplayFilters = displayFilters || null; + + const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan; + + const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; + + // Check if user can create issues in at least one project + const canCreateIssues = useCallback(() => { + if (!joinedProjectIds || joinedProjectIds.length === 0) return false; + return joinedProjectIds.some((projectId) => + allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ) + ); + }, [joinedProjectIds, allowPermissions, workspaceSlug]); + + // Quick add callback that wraps the quickAddIssue action + const handleQuickAddIssue = useCallback( + async (projectId: string | null | undefined, data: TIssue) => { + if (!projectId || !quickAddIssue) return; + return await quickAddIssue(projectId, data); + }, + [quickAddIssue] + ); + + const scrollableContainerRef = useRef(null); + + // states + const [draggedIssueId, setDraggedIssueId] = useState(undefined); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + + // Permission callback for per-project permission check + const canEditPropertiesBasedOnProject = useCallback( + (projectId: string | undefined) => { + if (!projectId) return false; + return allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + projectId + ); + }, + [allowPermissions, workspaceSlug] + ); + + const handleOnDrop = useGroupIssuesDragNDrop(EIssuesStoreType.GLOBAL, orderBy, group_by, sub_group_by); + + const canEditProperties = useCallback( + (projectId: string | undefined) => { + const isEditingAllowedBasedOnProject = canEditPropertiesBasedOnProject(projectId); + return enableInlineEditing && isEditingAllowedBasedOnProject; + }, + [canEditPropertiesBasedOnProject, enableInlineEditing] + ); + + // Enable Auto Scroll for Main Kanban + useEffect(() => { + const element = scrollableContainerRef.current; + + if (!element) return; + + return combine( + autoScrollForElements({ + element, + }) + ); + }, []); + + // Make the Issue Delete Box a Drop Target + useEffect(() => { + const element = deleteAreaRef.current; + + if (!element) return; + + return combine( + dropTargetForElements({ + element, + getData: () => ({ columnId: "issue-trash-box", groupId: "issue-trash-box", type: "DELETE" }), + onDragEnter: () => { + setIsDragOverDelete(true); + }, + onDragLeave: () => { + setIsDragOverDelete(false); + }, + onDrop: (payload) => { + setIsDragOverDelete(false); + const source = getSourceFromDropPayload(payload); + + if (!source) return; + + setDraggedIssueId(source.id); + setDeleteIssueModal(true); + }, + }) + ); + }, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]); + + const renderQuickActions: TRenderQuickActions = useCallback( + ({ issue, parentRef, customActionButton }) => ( + removeIssue(issue.project_id, issue.id)} + handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)} + handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} + readOnly={!canEditProperties(issue.project_id ?? undefined)} + /> + ), + [canEditProperties, removeIssue, updateIssue, archiveIssue] + ); + + const handleDeleteIssue = async () => { + const draggedIssue = getIssueById(draggedIssueId ?? ""); + + if (!draggedIssueId || !draggedIssue) return; + + try { + await removeIssue(draggedIssue.project_id, draggedIssueId); + setDeleteIssueModal(false); + setDraggedIssueId(undefined); + } catch (_error) { + setDeleteIssueModal(false); + setDraggedIssueId(undefined); + } + }; + + const handleCollapsedGroups = useCallback( + (toggle: "group_by" | "sub_group_by", value: string) => { + if (workspaceSlug) { + let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; + if (collapsedGroups.includes(value)) { + collapsedGroups = collapsedGroups.filter((_value) => _value != value); + } else { + collapsedGroups.push(value); + } + // projectId is not used for workspace-level filters + void updateFilters("", EIssueFilterType.KANBAN_FILTERS, { + [toggle]: collapsedGroups, + }); + } + }, + [workspaceSlug, issuesFilter, updateFilters] + ); + + const collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; + + return ( + <> + setDeleteIssueModal(false)} + onSubmit={handleDeleteIssue} + isEpic={false} + /> + {/* drag and delete component */} +
+
+ Drop here to delete the work item. +
+
+ +
+
+
+ +
+
+
+
+ + ); +}); diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/index.ts b/apps/web/core/components/issues/issue-layouts/quick-add/index.ts index a82947248ff..225c42791e6 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-add/index.ts +++ b/apps/web/core/components/issues/issue-layouts/quick-add/index.ts @@ -1,3 +1,4 @@ export * from "./root"; +export * from "./workspace-root"; export * from "./form"; export * from "./button"; diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx index d0505121de6..01ddb4398c5 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-add/root.tsx @@ -14,6 +14,7 @@ import { cn, createIssuePayload } from "@plane/utils"; import { QuickAddIssueFormRoot } from "@/plane-web/components/issues/quick-add"; // local imports import { CreateIssueToastActionItems } from "../../create-issue-toast-action-items"; +import { WorkspaceQuickAddIssueRoot } from "./workspace-root"; export type TQuickAddIssueForm = { ref: React.RefObject; @@ -61,7 +62,7 @@ export const QuickAddIssueRoot = observer(function QuickAddIssueRoot(props: TQui // i18n const { t } = useTranslation(); // router - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug, projectId, globalViewId } = useParams(); // states const [isOpen, setIsOpen] = useState(isQuickAddOpen ?? false); // form info @@ -128,6 +129,24 @@ export const QuickAddIssueRoot = observer(function QuickAddIssueRoot(props: TQui } }; + // For workspace-level views (no projectId but has globalViewId), use workspace quick add + if (!projectId && globalViewId) { + return ( + + ); + } + + // No project context and not workspace level - can't quick add if (!projectId) return null; return ( diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx new file mode 100644 index 00000000000..f46a5750f91 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx @@ -0,0 +1,216 @@ +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useForm } from "react-hook-form"; +// plane imports +import { useTranslation } from "@plane/i18n"; +import { PlusIcon } from "@plane/propel/icons"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { TIssue, EIssueLayoutTypes } from "@plane/types"; +import { cn, createIssuePayload } from "@plane/utils"; +// components +import { ProjectDropdown } from "@/components/dropdowns/project/dropdown"; +// plane web imports +import { QuickAddIssueFormRoot } from "@/plane-web/components/issues/quick-add"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useProjectState } from "@/hooks/store/use-project-state"; +// local imports +import { CreateIssueToastActionItems } from "../../create-issue-toast-action-items"; +import { findStateByGroup } from "../utils"; + +export type TWorkspaceQuickAddIssueButton = { + isEpic?: boolean; + onClick: () => void; +}; + +type TWorkspaceQuickAddIssueRoot = { + isQuickAddOpen?: boolean; + layout: EIssueLayoutTypes; + prePopulatedData?: Partial; + QuickAddButton?: FC; + customQuickAddButton?: React.ReactNode; + containerClassName?: string; + setIsQuickAddOpen?: (isOpen: boolean) => void; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; + isEpic?: boolean; +}; + +const defaultValues: Partial = { + name: "", +}; + +export const WorkspaceQuickAddIssueRoot = observer(function WorkspaceQuickAddIssueRoot( + props: TWorkspaceQuickAddIssueRoot +) { + const { + isQuickAddOpen, + layout, + prePopulatedData, + QuickAddButton, + customQuickAddButton, + containerClassName = "", + setIsQuickAddOpen, + quickAddCallback, + isEpic = false, + } = props; + // i18n + const { t } = useTranslation(); + // router + const { workspaceSlug } = useParams(); + // store hooks + const { joinedProjectIds } = useProject(); + const { getProjectStates } = useProjectState(); + // states + const [isOpen, setIsOpen] = useState(isQuickAddOpen ?? false); + const [selectedProjectId, setSelectedProjectId] = useState(null); + + // Map state_detail.group from prePopulatedData to an actual state_id for the selected project + const resolvedPrePopulatedData = useMemo(() => { + if (!selectedProjectId || !prePopulatedData) return prePopulatedData; + + // Check if prePopulatedData has state_detail.group that needs to be resolved + const stateGroup = (prePopulatedData as Record)["state_detail.group"] as string | undefined; + if (!stateGroup) return prePopulatedData; + + // Find a state in the selected project that belongs to this state group + const projectStates = getProjectStates(selectedProjectId); + const targetState = findStateByGroup(projectStates, stateGroup); + + if (targetState) { + // Return prePopulatedData with state_id set and state_detail.group removed + const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record; + return { ...rest, state_id: targetState.id } as Partial; + } + + return prePopulatedData; + }, [selectedProjectId, prePopulatedData, getProjectStates]); + // form info + const { + reset, + handleSubmit, + setFocus, + register, + formState: { errors, isSubmitting }, + } = useForm({ defaultValues }); + + // Set default project when opening + useEffect(() => { + if (isOpen && !selectedProjectId && joinedProjectIds && joinedProjectIds.length > 0) { + setSelectedProjectId(joinedProjectIds[0]); + } + }, [isOpen, selectedProjectId, joinedProjectIds]); + + useEffect(() => { + if (isQuickAddOpen !== undefined) { + setIsOpen(isQuickAddOpen); + } + }, [isQuickAddOpen]); + + useEffect(() => { + if (!isOpen) { + reset({ ...defaultValues }); + setSelectedProjectId(null); + } + }, [isOpen, reset]); + + const handleIsOpen = (isOpen: boolean) => { + if (isQuickAddOpen !== undefined && setIsQuickAddOpen) { + setIsQuickAddOpen(isOpen); + } else { + setIsOpen(isOpen); + } + }; + + const onSubmitHandler = async (formData: TIssue) => { + if (isSubmitting || !workspaceSlug || !selectedProjectId) return; + + reset({ ...defaultValues }); + + const payload = createIssuePayload(selectedProjectId, { + ...(resolvedPrePopulatedData ?? {}), + ...formData, + }); + + if (quickAddCallback) { + const quickAddPromise = quickAddCallback(selectedProjectId, { ...payload }); + setPromiseToast(quickAddPromise, { + loading: isEpic ? t("epic.adding") : t("issue.adding"), + success: { + title: t("common.success"), + message: () => `${isEpic ? t("epic.create.success") : t("issue.create.success")}`, + actionItems: (data: TIssue) => ( + + ), + }, + error: { + title: t("common.error.label"), + message: (err: { message?: string }) => err?.message || t("common.error.message"), + }, + }); + + await quickAddPromise; + } + }; + + return ( +
+ {isOpen ? ( +
+ {/* Project selector */} +
+ Project: + setSelectedProjectId(projectId)} + multiple={false} + buttonVariant="border-with-text" + buttonClassName="text-13" + placeholder="Select project" + /> +
+ {/* Quick add form */} + {selectedProjectId && ( + void handleSubmit(onSubmitHandler)()} + onClose={() => handleIsOpen(false)} + isEpic={isEpic} + /> + )} +
+ ) : ( + <> + {QuickAddButton && handleIsOpen(true)} />} + {customQuickAddButton && <>{customQuickAddButton}} + {!QuickAddButton && !customQuickAddButton && ( + + )} + + )} +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx index ecdf1e66fa7..bc63d386058 100644 --- a/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/roots/all-issue-layout-root.tsx @@ -37,10 +37,9 @@ export const AllIssueLayoutRoot = observer(function AllIssueLayoutRoot(props: Pr // search params const searchParams = useSearchParams(); // store hooks - const { - issuesFilter: { filters, fetchFilters, updateFilterExpression }, - issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues }, - } = useIssues(EIssuesStoreType.GLOBAL); + const { issuesFilter, issues } = useIssues(EIssuesStoreType.GLOBAL); + const { filters, fetchFilters, updateFilterExpression } = issuesFilter; + const { clear, groupedIssueIds, fetchIssues, fetchNextIssues } = issues; const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView(); // Derived values const viewDetails = globalViewId ? getViewDetailsById(globalViewId) : undefined; @@ -96,10 +95,20 @@ export const AllIssueLayoutRoot = observer(function AllIssueLayoutRoot(props: Pr clear(); toggleLoading(true); await fetchFilters(workspaceSlug, globalViewId); - await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", { - canGroup: false, - perPageCount: 100, - }); + // Get the layout after filters are fetched to determine if grouping is needed + // Access issuesFilter.filters directly to get the updated value from the store + const currentFilters = issuesFilter.filters?.[globalViewId]; + const layout = currentFilters?.displayFilters?.layout; + // Calendar layout needs date-range parameters that only the calendar component can provide + // Don't fetch here for calendar - let the calendar component handle it + if (layout !== "calendar") { + // Kanban layout needs grouped data + const needsGrouping = layout === "kanban"; + await fetchIssues(workspaceSlug, globalViewId, groupedIssueIds ? "mutation" : "init-loader", { + canGroup: needsGrouping, + perPageCount: needsGrouping ? 30 : 100, + }); + } toggleLoading(false); } }, diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index 1417343b8ae..ac5666f1111 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -10,6 +10,7 @@ import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } f import type { GroupByColumnTypes, IGroupByColumn, + IState, TCycleGroups, IIssueDisplayProperties, IPragmaticDropPayload, @@ -56,6 +57,29 @@ export type IssueUpdates = { }; }; +/** + * Find a state in a project that belongs to a specific state group. + * Prefers the default state for the group, falls back to first match. + * + * Used by workspace-level views where issues are grouped by state_detail.group + * instead of state_id (since states are project-specific). + * + * @param projectStates - Array of states for a project + * @param targetStateGroup - The state group to find a state for (e.g., "backlog", "started") + * @returns The matching state, or undefined if no match found + */ +export const findStateByGroup = ( + projectStates: IState[] | undefined, + targetStateGroup: string +): IState | undefined => { + if (!projectStates) return undefined; + + return ( + projectStates.find((s) => s.group === targetStateGroup && s.default) || + projectStates.find((s) => s.group === targetStateGroup) + ); +}; + export const isWorkspaceLevel = (type: EIssuesStoreType) => [ EIssuesStoreType.PROFILE, @@ -538,41 +562,76 @@ export const handleGroupDragDrop = async ( // update updatedIssue values based on the source and destination groupIds if (source.groupId && destination.groupId && source.groupId !== destination.groupId && groupBy) { - const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; - let groupValue: any = clone(sourceIssue[groupKey]); - - // If groupValues is an array, remove source groupId and add destination groupId - if (Array.isArray(groupValue)) { - pull(groupValue, source.groupId); - if (destination.groupId !== "None") groupValue = uniq(concat(groupValue, [destination.groupId])); - } // else just update the groupValue based on destination groupId - else { - groupValue = destination.groupId === "None" ? null : destination.groupId; + // Special handling for state_detail.group - need to map to actual state_id + if (groupBy === "state_detail.group") { + const { getProjectStates } = store.state; + const projectStates = sourceIssue.project_id ? getProjectStates(sourceIssue.project_id) : undefined; + const targetState = findStateByGroup(projectStates, destination.groupId); + + if (targetState) { + updatedIssue = { ...updatedIssue, state_id: targetState.id }; + issueUpdates["state_id"] = { + ADD: [targetState.id], + REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], + }; + } else if (projectStates) { + // No matching state found in project - cannot complete drag + throw new Error(`No state found for group "${destination.groupId}" in project`); + } + } else { + const groupKey = ISSUE_FILTER_DEFAULT_DATA[groupBy]; + let groupValue: any = clone(sourceIssue[groupKey]); + + // If groupValues is an array, remove source groupId and add destination groupId + if (Array.isArray(groupValue)) { + pull(groupValue, source.groupId); + if (destination.groupId !== "None") groupValue = uniq(concat(groupValue, [destination.groupId])); + } // else just update the groupValue based on destination groupId + else { + groupValue = destination.groupId === "None" ? null : destination.groupId; + } + + // keep track of updates on what was added and what was removed + issueUpdates[groupKey] = { ADD: getGroupId(destination.groupId), REMOVE: getGroupId(source.groupId) }; + updatedIssue = { ...updatedIssue, [groupKey]: groupValue }; } - - // keep track of updates on what was added and what was removed - issueUpdates[groupKey] = { ADD: getGroupId(destination.groupId), REMOVE: getGroupId(source.groupId) }; - updatedIssue = { ...updatedIssue, [groupKey]: groupValue }; } // do the same for subgroup // update updatedIssue values based on the source and destination subGroupIds if (subGroupBy && source.subGroupId && destination.subGroupId && source.subGroupId !== destination.subGroupId) { - const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; - let subGroupValue: any = clone(sourceIssue[subGroupKey]); - - // If subGroupValue is an array, remove source subGroupId and add destination subGroupId - if (Array.isArray(subGroupValue)) { - pull(subGroupValue, source.subGroupId); - if (destination.subGroupId !== "None") subGroupValue = uniq(concat(subGroupValue, [destination.subGroupId])); - } // else just update the subGroupValue based on destination subGroupId - else { - subGroupValue = destination.subGroupId === "None" ? null : destination.subGroupId; + // Special handling for state_detail.group as subGroupBy - need to map to actual state_id + if (subGroupBy === "state_detail.group") { + const { getProjectStates } = store.state; + const projectStates = sourceIssue.project_id ? getProjectStates(sourceIssue.project_id) : undefined; + const targetState = findStateByGroup(projectStates, destination.subGroupId); + + if (targetState) { + updatedIssue = { ...updatedIssue, state_id: targetState.id }; + issueUpdates["state_id"] = { + ADD: [targetState.id], + REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], + }; + } else if (projectStates) { + throw new Error(`No state found for group "${destination.subGroupId}" in project`); + } + } else { + const subGroupKey = ISSUE_FILTER_DEFAULT_DATA[subGroupBy]; + let subGroupValue: any = clone(sourceIssue[subGroupKey]); + + // If subGroupValue is an array, remove source subGroupId and add destination subGroupId + if (Array.isArray(subGroupValue)) { + pull(subGroupValue, source.subGroupId); + if (destination.subGroupId !== "None") subGroupValue = uniq(concat(subGroupValue, [destination.subGroupId])); + } // else just update the subGroupValue based on destination subGroupId + else { + subGroupValue = destination.subGroupId === "None" ? null : destination.subGroupId; + } + + // keep track of updates on what was added and what was removed + issueUpdates[subGroupKey] = { ADD: getGroupId(destination.subGroupId), REMOVE: getGroupId(source.subGroupId) }; + updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; } - - // keep track of updates on what was added and what was removed - issueUpdates[subGroupKey] = { ADD: getGroupId(destination.subGroupId), REMOVE: getGroupId(source.subGroupId) }; - updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; } if (updatedIssue && sourceIssue?.project_id) { diff --git a/apps/web/core/hooks/store/use-issues.ts b/apps/web/core/hooks/store/use-issues.ts index 33e6023fdff..49b149d6845 100644 --- a/apps/web/core/hooks/store/use-issues.ts +++ b/apps/web/core/hooks/store/use-issues.ts @@ -1,6 +1,6 @@ import { useContext } from "react"; import { merge } from "lodash-es"; -import type { TIssueMap } from "@plane/types"; +import type { TIssue, TIssueMap } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; import { StoreContext } from "@/lib/store-context"; // plane web types @@ -22,6 +22,7 @@ import type { IWorkspaceDraftIssues, IWorkspaceDraftIssuesFilter } from "@/store type defaultIssueStore = { issueMap: TIssueMap; + addIssuesToMap: (issues: TIssue[]) => void; }; export type TStoreIssues = { @@ -85,6 +86,7 @@ export const useIssues = (storeType?: T): TStoreIssu const defaultStore: defaultIssueStore = { issueMap: context.issue.issues.issuesMap, + addIssuesToMap: (issues: TIssue[]) => context.issue.issues.addIssue(issues), }; switch (storeType) { diff --git a/apps/web/core/hooks/use-group-dragndrop.ts b/apps/web/core/hooks/use-group-dragndrop.ts index f1929283b0a..9ea8797b80e 100644 --- a/apps/web/core/hooks/use-group-dragndrop.ts +++ b/apps/web/core/hooks/use-group-dragndrop.ts @@ -19,7 +19,8 @@ type DNDStoreType = | EIssuesStoreType.TEAM | EIssuesStoreType.TEAM_VIEW | EIssuesStoreType.EPIC - | EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS; + | EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS + | EIssuesStoreType.GLOBAL; export const useGroupIssuesDragNDrop = ( storeType: DNDStoreType, diff --git a/apps/web/core/hooks/use-issues-actions.tsx b/apps/web/core/hooks/use-issues-actions.tsx index c14c951fa59..f3140c1f60d 100644 --- a/apps/web/core/hooks/use-issues-actions.tsx +++ b/apps/web/core/hooks/use-issues-actions.tsx @@ -685,9 +685,10 @@ const useGlobalIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.GLOBAL); const fetchIssues = useCallback( - async (loadType: TLoader, options: IssuePaginationOptions) => { - if (!workspaceSlug || !globalViewId) return; - return issues.fetchIssues(workspaceSlug.toString(), globalViewId.toString(), loadType, options); + async (loadType: TLoader, options: IssuePaginationOptions, viewId?: string) => { + const effectiveViewId = viewId ?? globalViewId; + if (!workspaceSlug || !effectiveViewId) return; + return issues.fetchIssues(workspaceSlug.toString(), effectiveViewId.toString(), loadType, options); }, [issues.fetchIssues, workspaceSlug, globalViewId] ); @@ -720,11 +721,26 @@ const useGlobalIssueActions = () => { }, [issues.removeIssue, workspaceSlug] ); + const archiveIssue = useCallback( + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.archiveIssue(workspaceSlug, projectId, issueId); + }, + [issues.archiveIssue, workspaceSlug] + ); + const quickAddIssue = useCallback( + async (projectId: string | undefined | null, data: TIssue) => { + if (!workspaceSlug || !projectId) return; + return await issues.quickAddIssue(workspaceSlug, projectId, data); + }, + [issues.quickAddIssue, workspaceSlug] + ); const updateFilters = useCallback( - async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { + async (_projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { if (!globalViewId || !workspaceSlug) return; - return await issuesFilter.updateFilters(workspaceSlug, projectId, filterType, filters, globalViewId); + // _projectId is ignored for workspace-level filters, but kept in signature for interface compatibility + return await issuesFilter.updateFilters(workspaceSlug, undefined, filterType, filters, globalViewId); }, [issuesFilter.updateFilters, globalViewId, workspaceSlug] ); @@ -734,11 +750,13 @@ const useGlobalIssueActions = () => { fetchIssues, fetchNextIssues, createIssue, + quickAddIssue, updateIssue, removeIssue, + archiveIssue, updateFilters, }), - [createIssue, updateIssue, removeIssue, updateFilters] + [fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters] ); }; @@ -793,7 +811,7 @@ const useWorkspaceDraftIssueActions = () => { // ); const updateFilters = useCallback( - async (projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { + async (_projectId: string, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate) => { filters = filters as IIssueDisplayFilterOptions | IIssueDisplayProperties; if (!globalViewId || !workspaceSlug) return; return await issuesFilter.updateFilters(workspaceSlug, filterType, filters); diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts index c544348266b..59bda83c6ef 100644 --- a/apps/web/core/services/workspace.service.ts +++ b/apps/web/core/services/workspace.service.ts @@ -263,7 +263,7 @@ export class WorkspaceService extends APIService { }); } - async getViewIssues(workspaceSlug: string, params: any, config = {}): Promise { + async getViewIssues(workspaceSlug: string, params: any, config = {}): Promise { const path = params.expand?.includes("issue_relation") ? `/api/workspaces/${workspaceSlug}/issues-detail/` : `/api/workspaces/${workspaceSlug}/issues/`; @@ -276,7 +276,11 @@ export class WorkspaceService extends APIService { ) .then((response) => response?.data) .catch((error) => { - throw error?.response?.data; + // Don't throw for aborted requests - they're expected when switching views/layouts + if (error?.code === "ERR_CANCELED" || error?.name === "CanceledError" || error?.name === "AbortError") { + return undefined; + } + throw error?.response?.data ?? error; }); } diff --git a/apps/web/core/store/issue/helpers/base-issues.store.ts b/apps/web/core/store/issue/helpers/base-issues.store.ts index f336c225cb4..b5eaf85a002 100644 --- a/apps/web/core/store/issue/helpers/base-issues.store.ts +++ b/apps/web/core/store/issue/helpers/base-issues.store.ts @@ -59,6 +59,8 @@ export interface IBaseIssuesStore { //actions removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; clear(shouldClearPaginationOptions?: boolean): void; + clearIssueIds(): void; + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string): void; // helper methods getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; issuesSortWithOrderBy(issueIds: string[], key: Partial): string[]; @@ -219,6 +221,7 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { onfetchIssues: action.bound, onfetchNexIssues: action.bound, clear: action.bound, + clearIssueIds: action.bound, setLoader: action.bound, addIssue: action.bound, removeIssueFromList: action.bound, @@ -1157,6 +1160,17 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore { this.controller = new AbortController(); } + /** + * Clears only the grouped issue IDs without aborting pending requests. + * Used when switching layouts to immediately show loader state. + */ + clearIssueIds() { + runInAction(() => { + this.groupedIssueIds = undefined; + this.groupedIssueCount = {}; + }); + } + /** * Method called to add issue id to list. * This will only work if the issue already exists in the main issue map diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 630429bb380..19ab7ee4d7d 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx" import { computedFn } from "mobx-utils"; // plane imports import type { TSupportedFilterTypeForUpdate } from "@plane/constants"; -import { EIssueFilterType } from "@plane/constants"; +import { EIssueFilterType, WORKSPACE_KANBAN_GROUP_BY_OPTIONS } from "@plane/constants"; import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, @@ -31,14 +31,14 @@ export type TBaseFilterStore = IBaseIssueFilterStore & IIssueFilterHelperStore; export interface IWorkspaceIssuesFilter extends TBaseFilterStore { // fetch action fetchFilters: (workspaceSlug: string, viewId: string) => Promise; - updateFilterExpression: (workspaceSlug: string, viewId: string, filters: TWorkItemFilterExpression) => Promise; + updateFilterExpression: (workspaceSlug: string, viewId: string, filters: TWorkItemFilterExpression) => void; updateFilters: ( workspaceSlug: string, projectId: string | undefined, filterType: TSupportedFilterTypeForUpdate, filters: TSupportedFilterForUpdate, viewId: string - ) => Promise; + ) => void; //helper action getIssueFilters: (viewId: string | undefined) => IIssueFilters | undefined; getAppliedFilters: (viewId: string) => Partial> | undefined; @@ -94,7 +94,10 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo const userFilters = this.getIssueFilters(viewId); if (!userFilters) return undefined; - const filteredParams = handleIssueQueryParamsByLayout(EIssueLayoutTypes.SPREADSHEET, "my_issues"); + // Use the current layout to get the correct filter params + + const currentLayout = (userFilters?.displayFilters?.layout ?? EIssueLayoutTypes.SPREADSHEET) as EIssueLayoutTypes; + const filteredParams = handleIssueQueryParamsByLayout(currentLayout, "my_issues"); if (!filteredParams) return undefined; const filteredRouteParams: Partial> = this.computedFilteredParams( @@ -179,6 +182,18 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo displayFilters.order_by = "-created_at"; } + // Set default group_by for kanban layout if not already set or incompatible + if (displayFilters.layout === "kanban") { + if (!displayFilters.group_by || !WORKSPACE_KANBAN_GROUP_BY_OPTIONS.includes(displayFilters.group_by as typeof WORKSPACE_KANBAN_GROUP_BY_OPTIONS[number])) { + displayFilters.group_by = "state_detail.group"; + } + } + + // Set calendar defaults if layout is calendar + if (displayFilters.layout === "calendar" && !displayFilters.calendar) { + displayFilters.calendar = { layout: "month", show_weekends: true }; + } + runInAction(() => { set(this.filters, [viewId, "richFilters"], richFilters); set(this.filters, [viewId, "displayFilters"], displayFilters); @@ -192,20 +207,21 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo * Only use this method directly when initializing filter instances. * For regular filter updates, use this method as a fallback function for the work item filter store methods instead. */ - updateFilterExpression: IWorkspaceIssuesFilter["updateFilterExpression"] = async (workspaceSlug, viewId, filters) => { + updateFilterExpression: IWorkspaceIssuesFilter["updateFilterExpression"] = (workspaceSlug, viewId, filters) => { try { runInAction(() => { set(this.filters, [viewId, "richFilters"], filters); }); - this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + // Fire-and-forget: UI updates optimistically, fetch runs in background + void this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); } catch (error) { console.log("error while updating rich filters", error); throw error; } }; - updateFilters: IWorkspaceIssuesFilter["updateFilters"] = async (workspaceSlug, projectId, type, filters, viewId) => { + updateFilters: IWorkspaceIssuesFilter["updateFilters"] = (workspaceSlug, _projectId, type, filters, viewId) => { try { const issueFilters = this.getIssueFilters(viewId); @@ -236,10 +252,28 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo _filters.displayFilters.sub_group_by = null; updatedDisplayFilters.sub_group_by = null; } - // set group_by to state if layout is switched to kanban and group_by is null - if (_filters.displayFilters.layout === "kanban" && _filters.displayFilters.group_by === null) { - _filters.displayFilters.group_by = "state"; - updatedDisplayFilters.group_by = "state"; + // set group_by to state_detail.group if layout is switched to kanban and group_by is null or incompatible + // For workspace views, we use state_detail.group instead of state (which is project-specific) + if (_filters.displayFilters.layout === "kanban") { + if ( + !_filters.displayFilters.group_by || + !WORKSPACE_KANBAN_GROUP_BY_OPTIONS.includes(_filters.displayFilters.group_by as typeof WORKSPACE_KANBAN_GROUP_BY_OPTIONS[number]) + ) { + _filters.displayFilters.group_by = "state_detail.group"; + updatedDisplayFilters.group_by = "state_detail.group"; + } + } + // Set calendar defaults if layout is switched to calendar + if (_filters.displayFilters.layout === "calendar" && !_filters.displayFilters.calendar) { + _filters.displayFilters.calendar = { layout: "month", show_weekends: true }; + updatedDisplayFilters.calendar = { layout: "month", show_weekends: true }; + } + + // When layout changes, clear issue IDs BEFORE updating the layout + // This ensures IssueLayoutHOC shows the loader immediately (due to issueCount being undefined) + // instead of trying to render with data in the wrong format + if (updatedDisplayFilters.layout) { + this.rootIssueStore.workspaceIssues.clearIssueIds(); } runInAction(() => { @@ -252,7 +286,23 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); }); - this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + // Fetch issues when display filters change + // Fire-and-forget pattern: UI updates optimistically via MobX, fetches run in background + if (updatedDisplayFilters.layout === "calendar") { + // Calendar layout needs date-range parameters that only the component can provide + // Don't fetch here - let the calendar component handle it + } else if (updatedDisplayFilters.layout) { + // Layout is changing to kanban or spreadsheet - fetch with correct canGroup + const needsGrouping = _filters.displayFilters.layout === "kanban"; + void this.rootIssueStore.workspaceIssues.fetchIssues( + workspaceSlug, + viewId, + "init-loader", + { canGroup: needsGrouping, perPageCount: needsGrouping ? 30 : 100 } + ); + } else { + void this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + } if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, { @@ -306,7 +356,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo break; } } catch (error) { - if (viewId) this.fetchFilters(workspaceSlug, viewId); + if (viewId) void this.fetchFilters(workspaceSlug, viewId); throw error; } }; diff --git a/apps/web/core/store/issue/workspace/issue.store.ts b/apps/web/core/store/issue/workspace/issue.store.ts index 9fcf1734069..107d25210b8 100644 --- a/apps/web/core/store/issue/workspace/issue.store.ts +++ b/apps/web/core/store/issue/workspace/issue.store.ts @@ -45,7 +45,7 @@ export interface IWorkspaceIssues extends IBaseIssuesStore { archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; - quickAddIssue: undefined; + quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; clear(): void; } @@ -109,6 +109,12 @@ export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues signal: this.controller.signal, }); + // If request was aborted, response will be undefined - skip processing + if (!response) { + this.setLoader(undefined); + return undefined; + } + // after fetching issues, call the base method to process the response further this.onfetchIssues(response, options, workspaceSlug, undefined, undefined, !isExistingPaginationOptions); return response; @@ -148,6 +154,12 @@ export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues // call the fetch issues API with the params for next page in issues const response = await this.workspaceService.getViewIssues(workspaceSlug, params); + // Skip processing if response is undefined (e.g., aborted request) + if (!response) { + this.setLoader(undefined, groupId, subGroupId); + return undefined; + } + // after the next page of issues are fetched, call the base method to process the response this.onfetchNexIssues(response, groupId, subGroupId); return response; @@ -176,6 +188,36 @@ export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues updateIssue = this.issueUpdate; archiveIssue = this.issueArchive; - // Setting them as undefined as they can not performed on workspace issues - quickAddIssue = undefined; + /** + * Quick add issue for workspace views + * Adds a temporary issue optimistically, then creates it via API + * @param workspaceSlug + * @param projectId - Required for workspace views since there's no project context + * @param data + * @returns + */ + quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue) => { + try { + // Add temporary issue to store for optimistic UI + this.addIssue(data); + + // Create issue via API + const response = await this.createIssue(workspaceSlug, projectId, data); + + // Remove temporary issue and add real one + runInAction(() => { + this.removeIssueFromList(data.id); + this.rootIssueStore.issues.removeIssue(data.id); + }); + + return response; + } catch (error) { + // Remove temporary issue on error + runInAction(() => { + this.removeIssueFromList(data.id); + this.rootIssueStore.issues.removeIssue(data.id); + }); + throw error; + } + }; } diff --git a/packages/constants/src/issue/common.ts b/packages/constants/src/issue/common.ts index 43460b05196..5ed910caaf5 100644 --- a/packages/constants/src/issue/common.ts +++ b/packages/constants/src/issue/common.ts @@ -86,6 +86,7 @@ export const ISSUE_PRIORITIES: { export const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = [ "state", + "state_detail.group", "priority", "assignees", "labels", diff --git a/packages/constants/src/issue/filter.ts b/packages/constants/src/issue/filter.ts index 126f886092c..879b5e2ccda 100644 --- a/packages/constants/src/issue/filter.ts +++ b/packages/constants/src/issue/filter.ts @@ -37,6 +37,12 @@ export type TSupportedFilterTypeForUpdate = | EIssueFilterType.DISPLAY_PROPERTIES | EIssueFilterType.KANBAN_FILTERS; +/** + * Valid group_by options for workspace-level Kanban views. + * Uses state_detail.group instead of state since states are project-specific. + */ +export const WORKSPACE_KANBAN_GROUP_BY_OPTIONS = ["state_detail.group", "priority", "project", "labels"] as const; + export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { [key in TIssueLayout]: Record<"filters", TIssueFilterKeys[]>; } = { @@ -122,7 +128,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { kanban: { display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, display_filters: { - group_by: ["state_detail.group", "priority", "project", "labels"], + group_by: [...WORKSPACE_KANBAN_GROUP_BY_OPTIONS], order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], type: ["active", "backlog"], }, @@ -195,6 +201,28 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = { values: [], }, }, + kanban: { + display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS, + display_filters: { + group_by: [...WORKSPACE_KANBAN_GROUP_BY_OPTIONS], + order_by: ["-created_at", "-updated_at", "start_date", "-priority"], + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: ["show_empty_groups"], + }, + }, + calendar: { + display_properties: ["key", "issue_type"], + display_filters: { + type: ["active", "backlog"], + }, + extra_options: { + access: true, + values: [], + }, + }, }, }, issues: { From a0f7cb977b41a3a8c2ee3ac7adb7cfac2741b212 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:42:04 -0500 Subject: [PATCH 02/20] fix(workspace-views): Add viewId param to fetchNextIssues for consistent pagination Mirror the effectiveViewId logic from fetchIssues in fetchNextIssues so that workspace-level views using an explicit viewId paginate correctly. Also fix dependency array referencing fetchIssues instead of fetchNextIssues. --- apps/web/core/hooks/use-issues-actions.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/core/hooks/use-issues-actions.tsx b/apps/web/core/hooks/use-issues-actions.tsx index f3140c1f60d..873d415d336 100644 --- a/apps/web/core/hooks/use-issues-actions.tsx +++ b/apps/web/core/hooks/use-issues-actions.tsx @@ -28,7 +28,7 @@ export interface IssueActions { options: IssuePaginationOptions, viewId?: string ) => Promise; - fetchNextIssues: (groupId?: string, subGroupId?: string) => Promise; + fetchNextIssues: (groupId?: string, subGroupId?: string, viewId?: string) => Promise; removeIssue: (projectId: string | undefined | null, issueId: string) => Promise; createIssue?: (projectId: string | undefined | null, data: Partial) => Promise; quickAddIssue?: (projectId: string | undefined | null, data: TIssue) => Promise; @@ -693,11 +693,12 @@ const useGlobalIssueActions = () => { [issues.fetchIssues, workspaceSlug, globalViewId] ); const fetchNextIssues = useCallback( - async (groupId?: string, subGroupId?: string) => { - if (!workspaceSlug || !globalViewId) return; - return issues.fetchNextIssues(workspaceSlug.toString(), globalViewId.toString(), groupId, subGroupId); + async (groupId?: string, subGroupId?: string, viewId?: string) => { + const effectiveViewId = viewId ?? globalViewId; + if (!workspaceSlug || !effectiveViewId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), effectiveViewId.toString(), groupId, subGroupId); }, - [issues.fetchIssues, workspaceSlug, globalViewId] + [issues.fetchNextIssues, workspaceSlug, globalViewId] ); const createIssue = useCallback( From ab687220742307b7a60b624282ede0bc6599bbd7 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:47:38 -0500 Subject: [PATCH 03/20] fix(workspace-calendar): Remove incorrect type annotation for getViewIssues response Let TypeScript infer the return type (TIssuesResponse | undefined) instead of explicitly annotating as TIssuesResponse, which is incorrect since getViewIssues can return undefined for canceled requests. --- .../issues/issue-layouts/calendar/roots/workspace-root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx index b09e6771c42..fa7f31d39c4 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -130,7 +130,7 @@ export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(pro target_date__isnull: "true", }; - const response: TIssuesResponse = await workspaceService.getViewIssues(workspaceSlug.toString(), params); + const response = await workspaceService.getViewIssues(workspaceSlug.toString(), params); if (response && response.results) { const results = response.results; From 3a8c9d4438fcfd70bdb9b51808ce104e5761849b Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:51:27 -0500 Subject: [PATCH 04/20] fix: re-check sub_group_by after normalizing group_by in workspace kanban When group_by is forced to state_detail.group for workspace kanban views, sub_group_by could still equal it, bypassing the earlier duplicate guard. Added a post-normalization check to nullify sub_group_by in that case. --- apps/web/core/store/issue/workspace/filter.store.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 19ab7ee4d7d..8491dca119f 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -262,6 +262,11 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo _filters.displayFilters.group_by = "state_detail.group"; updatedDisplayFilters.group_by = "state_detail.group"; } + // Re-check: nullify sub_group_by if it now matches the normalized group_by + if (_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } } // Set calendar defaults if layout is switched to calendar if (_filters.displayFilters.layout === "calendar" && !_filters.displayFilters.calendar) { From 6781620887da253922b5291353682c4036a70c8f Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:53:39 -0500 Subject: [PATCH 05/20] fix: correct useEffect dependencies in CalendarDayTile Remove dayTileRef.current from dependency array (ref mutations don't trigger re-renders) and add handleDragAndDrop and issues to prevent stale closures in the drop handler. --- .../core/components/issues/issue-layouts/calendar/day-tile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx index a174c99f18e..89593dade53 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx @@ -120,7 +120,7 @@ export const CalendarDayTile = observer(function CalendarDayTile(props: Props) { }, }) ); - }, [dayTileRef?.current, formattedDatePayload]); + }, [formattedDatePayload, handleDragAndDrop, issues]); if (!formattedDatePayload) return null; const issueIds = groupedIssueIds?.[formattedDatePayload]; From e161b0d877aea91f739bbc597e84b16c5ccb983c Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:55:18 -0500 Subject: [PATCH 06/20] fix: remove unused workspaceSlug from TLayoutSelectionProps The prop was defined in the type and passed by the caller but never used in GlobalViewLayoutSelection. Removed from both the type and the call site. --- .../(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx | 1 - apps/web/ce/components/views/helper.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index 961c6b7cd78..751fa86fe72 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -149,7 +149,6 @@ export const GlobalIssuesHeader = observer(function GlobalIssuesHeader() { )} {globalViewId && } diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx index 9335cc78bce..59af8d98c99 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -8,7 +8,6 @@ import { WorkspaceKanBanRoot } from "@/components/issues/issue-layouts/kanban/ro export type TLayoutSelectionProps = { onChange: (layout: EIssueLayoutTypes) => void; selectedLayout: EIssueLayoutTypes; - workspaceSlug: string; }; // Supported layouts for workspace views: Spreadsheet, Calendar, Kanban From 163d1c0b6f3ebdab8018b3f692b76175ab3c8e59 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 16:57:47 -0500 Subject: [PATCH 07/20] fix: validate group_by and sub_group_by fields in workspace view Reject invalid field names with a 400 error instead of silently returning empty groups. Validates against the set of fields supported by issue_group_values. --- apps/api/plane/app/views/view/base.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 64d0760be2e..a48509dd2bd 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -258,6 +258,23 @@ def list(self, request, slug): 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 From e21bceea9a2fc1b030a46e6ec146f79668eb3e71 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:08:07 -0500 Subject: [PATCH 08/20] fix: compute canCreateIssues once with useMemo instead of per call Replace useCallback returning a function with useMemo returning a boolean to avoid duplicate permission scans across joinedProjectIds on every render. Applied to both kanban and calendar workspace roots. --- .../issue-layouts/calendar/roots/workspace-root.tsx | 8 ++++---- .../issue-layouts/kanban/roots/workspace-root.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx index fa7f31d39c4..ee671942061 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -48,8 +48,8 @@ export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(pro const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; - // Check if user can create issues in at least one project - const canCreateIssues = useCallback(() => { + // Check if user can create issues in at least one project (computed once per render) + const canCreateIssues = useMemo(() => { if (!joinedProjectIds || joinedProjectIds.length === 0) return false; return joinedProjectIds.some((projectId) => allowPermissions( @@ -271,8 +271,8 @@ export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(pro // Workspace views are filter-based, not container-based like cycles/modules. // Issues appear based on their properties, not by being explicitly added to a view. addIssuesToView={undefined} - enableQuickIssueCreate={enableQuickAdd && canCreateIssues()} - disableIssueCreation={!enableIssueCreation || !canCreateIssues()} + enableQuickIssueCreate={enableQuickAdd && canCreateIssues} + disableIssueCreation={!enableIssueCreation || !canCreateIssues} quickAddCallback={handleQuickAddIssue} readOnly={false} updateFilters={updateFilters} diff --git a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx index 963efe08dd3..86d0481c1eb 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; @@ -78,8 +78,8 @@ export const WorkspaceKanBanRoot = observer(function WorkspaceKanBanRoot(props: const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; - // Check if user can create issues in at least one project - const canCreateIssues = useCallback(() => { + // Check if user can create issues in at least one project (computed once per render) + const canCreateIssues = useMemo(() => { if (!joinedProjectIds || joinedProjectIds.length === 0) return false; return joinedProjectIds.some((projectId) => allowPermissions( @@ -267,10 +267,10 @@ export const WorkspaceKanBanRoot = observer(function WorkspaceKanBanRoot(props: quickActions={renderQuickActions} handleCollapsedGroups={handleCollapsedGroups} collapsedGroups={collapsedGroups} - enableQuickIssueCreate={enableQuickAdd && canCreateIssues()} + enableQuickIssueCreate={enableQuickAdd && canCreateIssues} showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true} quickAddCallback={handleQuickAddIssue} - disableIssueCreation={!enableIssueCreation || !canCreateIssues()} + disableIssueCreation={!enableIssueCreation || !canCreateIssues} canEditProperties={canEditProperties} // Workspace views are filter-based, not container-based like cycles/modules. // Issues appear based on their properties, not by being explicitly added to a view. From 55361e1f6b507c0b5e8153fa44cdb7886868d8c7 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:13:33 -0500 Subject: [PATCH 09/20] fix: use API total_count for no-date issues and increase page size Use response.total_count instead of issueIds.length for accurate count when there are more no-date issues than the page size. Increased perPageCount from 50 to 500 to cover most real-world cases without needing full cursor-based pagination. --- .../issues/issue-layouts/calendar/roots/workspace-root.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx index ee671942061..da4b8a0c02d 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -114,8 +114,10 @@ export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(pro const fetchNoDateIssues = async () => { try { // Get base params from the filter store for the current view + // Use a high perPageCount to fetch all no-date issues in one request. + // Full cursor-based pagination can be added if datasets grow significantly. const baseParams = issuesFilter.getFilterParams( - { canGroup: false, perPageCount: 50 }, + { canGroup: false, perPageCount: 500 }, globalViewId, undefined, undefined, @@ -143,7 +145,7 @@ export const WorkspaceCalendarRoot = observer(function WorkspaceCalendarRoot(pro ); const issueIds = issues.map((issue) => issue.id); setNoDateIssueIds(issueIds); - setNoDateTotalCount(issueIds.length); + setNoDateTotalCount(response.total_count ?? issueIds.length); // Add issues to the issue map so they can be displayed if (issues.length > 0) { From 4bbd88d42a0b905c0c14e85e3e1c49ef351ec7c3 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:15:31 -0500 Subject: [PATCH 10/20] fix: let delete failures propagate to DeleteIssueModal Use .finally() for cleanup instead of try/catch that swallowed errors. This allows DeleteIssueModal to surface error toasts on failed deletes. --- .../issues/issue-layouts/kanban/roots/workspace-root.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx index 86d0481c1eb..2fbe7ffa62f 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx @@ -192,14 +192,10 @@ export const WorkspaceKanBanRoot = observer(function WorkspaceKanBanRoot(props: if (!draggedIssueId || !draggedIssue) return; - try { - await removeIssue(draggedIssue.project_id, draggedIssueId); + await removeIssue(draggedIssue.project_id, draggedIssueId).finally(() => { setDeleteIssueModal(false); setDraggedIssueId(undefined); - } catch (_error) { - setDeleteIssueModal(false); - setDraggedIssueId(undefined); - } + }); }; const handleCollapsedGroups = useCallback( From e90d7fc02e30dadc589afe346e8ce9f9039979ad Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:17:01 -0500 Subject: [PATCH 11/20] fix: avoid mutating store-backed collapsedGroups array Use spread to create a new array instead of push() which mutates the MobX store array before updateFilters runs. --- .../issue-layouts/kanban/roots/workspace-root.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx index 2fbe7ffa62f..4bcfab6340c 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx @@ -201,12 +201,10 @@ export const WorkspaceKanBanRoot = observer(function WorkspaceKanBanRoot(props: const handleCollapsedGroups = useCallback( (toggle: "group_by" | "sub_group_by", value: string) => { if (workspaceSlug) { - let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; - if (collapsedGroups.includes(value)) { - collapsedGroups = collapsedGroups.filter((_value) => _value != value); - } else { - collapsedGroups.push(value); - } + const currentGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; + const collapsedGroups = currentGroups.includes(value) + ? currentGroups.filter((_value) => _value != value) + : [...currentGroups, value]; // projectId is not used for workspace-level filters void updateFilters("", EIssueFilterType.KANBAN_FILTERS, { [toggle]: collapsedGroups, From 10a85c992643aaa31b3d895512116605f8996861 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:18:27 -0500 Subject: [PATCH 12/20] fix: always strip state_detail.group from quick-add payload Remove the synthetic state_detail.group key regardless of whether a matching state is found, preventing the API from receiving an unrecognized field. --- .../issues/issue-layouts/quick-add/workspace-root.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx index f46a5750f91..eaeba30174b 100644 --- a/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx @@ -78,13 +78,14 @@ export const WorkspaceQuickAddIssueRoot = observer(function WorkspaceQuickAddIss const projectStates = getProjectStates(selectedProjectId); const targetState = findStateByGroup(projectStates, stateGroup); + // Always strip state_detail.group from the payload — the API doesn't accept it + const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record; + if (targetState) { - // Return prePopulatedData with state_id set and state_detail.group removed - const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record; return { ...rest, state_id: targetState.id } as Partial; } - return prePopulatedData; + return rest as Partial; }, [selectedProjectId, prePopulatedData, getProjectStates]); // form info const { From c77db069e3b60e56af93272c9dfb90adb4d37bec Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 31 Jan 2026 17:19:53 -0500 Subject: [PATCH 13/20] fix: guard against missing project context in state-group DnD Throw early when sourceIssue.project_id is falsy instead of silently skipping the state_id update. Also simplify the else branch to always throw when no matching state is found, since projectStates is now guaranteed to be defined. --- .../components/issues/issue-layouts/utils.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web/core/components/issues/issue-layouts/utils.tsx b/apps/web/core/components/issues/issue-layouts/utils.tsx index ac5666f1111..3ee9c469192 100644 --- a/apps/web/core/components/issues/issue-layouts/utils.tsx +++ b/apps/web/core/components/issues/issue-layouts/utils.tsx @@ -565,7 +565,10 @@ export const handleGroupDragDrop = async ( // Special handling for state_detail.group - need to map to actual state_id if (groupBy === "state_detail.group") { const { getProjectStates } = store.state; - const projectStates = sourceIssue.project_id ? getProjectStates(sourceIssue.project_id) : undefined; + if (!sourceIssue.project_id) { + throw new Error("Cannot resolve state group without a project context"); + } + const projectStates = getProjectStates(sourceIssue.project_id); const targetState = findStateByGroup(projectStates, destination.groupId); if (targetState) { @@ -574,8 +577,7 @@ export const handleGroupDragDrop = async ( ADD: [targetState.id], REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], }; - } else if (projectStates) { - // No matching state found in project - cannot complete drag + } else { throw new Error(`No state found for group "${destination.groupId}" in project`); } } else { @@ -603,7 +605,10 @@ export const handleGroupDragDrop = async ( // Special handling for state_detail.group as subGroupBy - need to map to actual state_id if (subGroupBy === "state_detail.group") { const { getProjectStates } = store.state; - const projectStates = sourceIssue.project_id ? getProjectStates(sourceIssue.project_id) : undefined; + if (!sourceIssue.project_id) { + throw new Error("Cannot resolve state group without a project context"); + } + const projectStates = getProjectStates(sourceIssue.project_id); const targetState = findStateByGroup(projectStates, destination.subGroupId); if (targetState) { @@ -612,7 +617,7 @@ export const handleGroupDragDrop = async ( ADD: [targetState.id], REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], }; - } else if (projectStates) { + } else { throw new Error(`No state found for group "${destination.subGroupId}" in project`); } } else { From 22591c74c0f48248c0aade7bcdcbe4121efb0fe0 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:22:05 -0500 Subject: [PATCH 14/20] fix: use console.error with typed error in workspace filter store Co-Authored-By: Claude Opus 4.6 --- apps/web/core/store/issue/workspace/filter.store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 8491dca119f..a559c8fcbea 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -216,7 +216,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // Fire-and-forget: UI updates optimistically, fetch runs in background void this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); } catch (error) { - console.log("error while updating rich filters", error); + console.error("error while updating rich filters", error instanceof Error ? error.message : error); throw error; } }; From 58f6240c66c3fe3e4d6dff9ae2f9bb1dfc3d8962 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:22:49 -0500 Subject: [PATCH 15/20] perf: skip expensive subquery annotations when grouping is not requested Co-Authored-By: Claude Opus 4.6 --- apps/api/plane/utils/grouper.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index 1ec004e95ad..b7d97bc29a2 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -26,6 +26,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", From dc26cbec13a8b1c084089c8cae1a4ca1335c7b6c Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:43:04 -0500 Subject: [PATCH 16/20] fix: handle unhandled promise rejections in workspace filter store fire-and-forget calls Replace void-prefixed fire-and-forget calls with .catch() handlers to prevent unhandled promise rejections from background fetches. Co-Authored-By: Claude Opus 4.6 --- .../store/issue/workspace/filter.store.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 553908d54e1..0b76b719ec7 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -220,7 +220,11 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); // Fire-and-forget: UI updates optimistically, fetch runs in background - void this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + this.rootIssueStore.workspaceIssues + .fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation") + .catch((error) => { + console.error("error while fetching issues after rich filter update", error instanceof Error ? error.message : error); + }); } catch (error) { console.error("error while updating rich filters", error instanceof Error ? error.message : error); throw error; @@ -305,14 +309,20 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo } else if (updatedDisplayFilters.layout) { // Layout is changing to kanban or spreadsheet - fetch with correct canGroup const needsGrouping = _filters.displayFilters.layout === "kanban"; - void this.rootIssueStore.workspaceIssues.fetchIssues( + this.rootIssueStore.workspaceIssues.fetchIssues( workspaceSlug, viewId, "init-loader", { canGroup: needsGrouping, perPageCount: needsGrouping ? 30 : 100 } - ); + ).catch((error) => { + console.error("error while fetching issues after layout change", error instanceof Error ? error.message : error); + }); } else { - void this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); + this.rootIssueStore.workspaceIssues + .fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation") + .catch((error) => { + console.error("error while fetching issues after display filter update", error instanceof Error ? error.message : error); + }); } if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) @@ -367,7 +377,10 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo break; } } catch (error) { - if (viewId) void this.fetchFilters(workspaceSlug, viewId); + if (viewId) + this.fetchFilters(workspaceSlug, viewId).catch((err) => { + console.error("error while re-fetching filters", err instanceof Error ? err.message : err); + }); throw error; } }; From c23cb93f6ca8975aee315c052240e340b259f545 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:44:46 -0500 Subject: [PATCH 17/20] refactor: extract shared kanban/calendar layout defaults into applyLayoutDefaults helper Deduplicate the kanban group_by defaulting and calendar config defaulting logic that was repeated in both fetchFilters and updateFilters. Co-Authored-By: Claude Opus 4.6 --- .../store/issue/workspace/filter.store.ts | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 0b76b719ec7..84e83222bd5 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -83,6 +83,28 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo this.issueFilterService = new WorkspaceService(); } + /** + * Applies layout-specific defaults to display filters: + * - Kanban: defaults group_by to "state_detail.group" if unset or incompatible + * - Calendar: defaults calendar config if missing + */ + private applyLayoutDefaults(displayFilters: IIssueDisplayFilterOptions): void { + if (displayFilters.layout === "kanban") { + if ( + !displayFilters.group_by || + !WORKSPACE_KANBAN_GROUP_BY_OPTIONS.includes( + displayFilters.group_by as (typeof WORKSPACE_KANBAN_GROUP_BY_OPTIONS)[number] + ) + ) { + displayFilters.group_by = "state_detail.group"; + } + } + + if (displayFilters.layout === "calendar" && !displayFilters.calendar) { + displayFilters.calendar = { layout: "month", show_weekends: true }; + } + } + getIssueFilters = (viewId: string | undefined) => { if (!viewId) return undefined; @@ -188,17 +210,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo displayFilters.order_by = "-created_at"; } - // Set default group_by for kanban layout if not already set or incompatible - if (displayFilters.layout === "kanban") { - if (!displayFilters.group_by || !WORKSPACE_KANBAN_GROUP_BY_OPTIONS.includes(displayFilters.group_by as typeof WORKSPACE_KANBAN_GROUP_BY_OPTIONS[number])) { - displayFilters.group_by = "state_detail.group"; - } - } - - // Set calendar defaults if layout is calendar - if (displayFilters.layout === "calendar" && !displayFilters.calendar) { - displayFilters.calendar = { layout: "month", show_weekends: true }; - } + this.applyLayoutDefaults(displayFilters); runInAction(() => { set(this.filters, [viewId, "richFilters"], richFilters); @@ -262,26 +274,24 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo _filters.displayFilters.sub_group_by = null; updatedDisplayFilters.sub_group_by = null; } - // set group_by to state_detail.group if layout is switched to kanban and group_by is null or incompatible - // For workspace views, we use state_detail.group instead of state (which is project-specific) - if (_filters.displayFilters.layout === "kanban") { - if ( - !_filters.displayFilters.group_by || - !WORKSPACE_KANBAN_GROUP_BY_OPTIONS.includes(_filters.displayFilters.group_by as typeof WORKSPACE_KANBAN_GROUP_BY_OPTIONS[number]) - ) { - _filters.displayFilters.group_by = "state_detail.group"; - updatedDisplayFilters.group_by = "state_detail.group"; - } - // Re-check: nullify sub_group_by if it now matches the normalized group_by - if (_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by) { - _filters.displayFilters.sub_group_by = null; - updatedDisplayFilters.sub_group_by = null; - } + // Apply layout-specific defaults (kanban group_by, calendar config) + const prevGroupBy = _filters.displayFilters.group_by; + const prevCalendar = _filters.displayFilters.calendar; + this.applyLayoutDefaults(_filters.displayFilters); + // Sync any defaults that were applied back to updatedDisplayFilters for local storage persistence + if (_filters.displayFilters.group_by !== prevGroupBy) { + updatedDisplayFilters.group_by = _filters.displayFilters.group_by; + } + if (_filters.displayFilters.calendar !== prevCalendar) { + updatedDisplayFilters.calendar = _filters.displayFilters.calendar; } - // Set calendar defaults if layout is switched to calendar - if (_filters.displayFilters.layout === "calendar" && !_filters.displayFilters.calendar) { - _filters.displayFilters.calendar = { layout: "month", show_weekends: true }; - updatedDisplayFilters.calendar = { layout: "month", show_weekends: true }; + // Nullify sub_group_by if it now matches the normalized group_by (kanban-specific) + if ( + _filters.displayFilters.layout === "kanban" && + _filters.displayFilters.group_by === _filters.displayFilters.sub_group_by + ) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; } // When layout changes, clear issue IDs BEFORE updating the layout From c55a908c647efe0ce8a61d46f719511854345d0e Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:54:15 -0500 Subject: [PATCH 18/20] fix: resolve broken import path for WorkspaceService in workspace calendar root Change import from non-existent @/plane-web/services to @/services/workspace.service which is where WorkspaceService is defined. Co-Authored-By: Claude Opus 4.6 --- .../issues/issue-layouts/calendar/roots/workspace-root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx index da4b8a0c02d..756536a8f5d 100644 --- a/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -15,7 +15,7 @@ import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // services -import { WorkspaceService } from "@/plane-web/services"; +import { WorkspaceService } from "@/services/workspace.service"; // local imports import { CalendarChart } from "../calendar"; import { handleDragDrop } from "../utils"; From 38ea179d07cd8b8c54e5165c54764602cdf6d6f2 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 19:58:18 -0500 Subject: [PATCH 19/20] refactor: invert calendar condition to remove empty if branch Co-Authored-By: Claude Opus 4.6 --- apps/web/core/store/issue/workspace/filter.store.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 84e83222bd5..4e3b91e9bbc 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -313,10 +313,8 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // Fetch issues when display filters change // Fire-and-forget pattern: UI updates optimistically via MobX, fetches run in background - if (updatedDisplayFilters.layout === "calendar") { - // Calendar layout needs date-range parameters that only the component can provide - // Don't fetch here - let the calendar component handle it - } else if (updatedDisplayFilters.layout) { + // Calendar layout is skipped — it needs date-range parameters that only the component can provide + if (updatedDisplayFilters.layout && updatedDisplayFilters.layout !== "calendar") { // Layout is changing to kanban or spreadsheet - fetch with correct canGroup const needsGrouping = _filters.displayFilters.layout === "kanban"; this.rootIssueStore.workspaceIssues.fetchIssues( @@ -327,7 +325,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo ).catch((error) => { console.error("error while fetching issues after layout change", error instanceof Error ? error.message : error); }); - } else { + } else if (!updatedDisplayFilters.layout) { this.rootIssueStore.workspaceIssues .fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation") .catch((error) => { From abd22e7b64f24e4296fd3e44d625443252c69f94 Mon Sep 17 00:00:00 2001 From: Anton Abramov Date: Sat, 14 Feb 2026 20:07:30 -0500 Subject: [PATCH 20/20] fix: skip clearing issue IDs when layout is unchanged to avoid loader flash Co-Authored-By: Claude Opus 4.6 --- apps/web/core/store/issue/workspace/filter.store.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/core/store/issue/workspace/filter.store.ts b/apps/web/core/store/issue/workspace/filter.store.ts index 4e3b91e9bbc..22c18fda2ac 100644 --- a/apps/web/core/store/issue/workspace/filter.store.ts +++ b/apps/web/core/store/issue/workspace/filter.store.ts @@ -296,8 +296,9 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // When layout changes, clear issue IDs BEFORE updating the layout // This ensures IssueLayoutHOC shows the loader immediately (due to issueCount being undefined) - // instead of trying to render with data in the wrong format - if (updatedDisplayFilters.layout) { + // instead of trying to render with data in the wrong format. + // Skip the clear if the layout isn't actually changing to avoid unnecessary loader flashes. + if (updatedDisplayFilters.layout && updatedDisplayFilters.layout !== issueFilters.displayFilters?.layout) { this.rootIssueStore.workspaceIssues.clearIssueIds(); }