diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index 98fe04c62fa..a48509dd2bd 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,107 @@ def list(self, request, slug): issue_queryset=issue_queryset, order_by_param=order_by_param ) - # List Paginate - return self.paginate( - order_by=order_by_param, - request=request, - queryset=issue_queryset, - on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data, - total_count_queryset=total_issue_count_queryset, + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # Validate group_by and sub_group_by field names + ALLOWED_GROUP_BY_FIELDS = { + "state_id", "labels__id", "assignees__id", "issue_module__module_id", + "cycle_id", "project_id", "priority", "state__group", + "target_date", "start_date", "created_by", + } + if group_by and group_by not in ALLOWED_GROUP_BY_FIELDS: + return Response( + {"error": f"Invalid group_by field: {group_by}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if sub_group_by and sub_group_by not in ALLOWED_GROUP_BY_FIELDS: + return Response( + {"error": f"Invalid sub_group_by field: {sub_group_by}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Apply grouper to issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by ) + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + {"error": "Group by and sub group by cannot have same parameters"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # Sub-grouped paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=filtered_issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + queryset=filtered_issue_queryset, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=None, + filters=filters, + queryset=filtered_issue_queryset, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + total_count_queryset=filtered_issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + queryset=filtered_issue_queryset, + ), + group_by_field_name=group_by, + count_filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List paginate (no grouping) + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + total_count_queryset=filtered_issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) + class IssueViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer 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/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 155249e2d17..59af8d98c99 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -1,18 +1,59 @@ import type { EIssueLayoutTypes, IProjectView } from "@plane/types"; +import { EIssueLayoutTypes as LayoutTypes } from "@plane/types"; import type { TWorkspaceLayoutProps } from "@/components/views/helper"; +import { LayoutSelection } from "@/components/issues/issue-layouts/filters/header/layout-selection"; +import { WorkspaceCalendarRoot } from "@/components/issues/issue-layouts/calendar/roots/workspace-root"; +import { WorkspaceKanBanRoot } from "@/components/issues/issue-layouts/kanban/roots/workspace-root"; export type TLayoutSelectionProps = { onChange: (layout: EIssueLayoutTypes) => void; selectedLayout: EIssueLayoutTypes; - workspaceSlug: string; }; +// Supported layouts for workspace views: Spreadsheet, Calendar, Kanban +const WORKSPACE_VIEW_LAYOUTS: EIssueLayoutTypes[] = [ + LayoutTypes.SPREADSHEET, + LayoutTypes.CALENDAR, + LayoutTypes.KANBAN, +]; + export function GlobalViewLayoutSelection(props: TLayoutSelectionProps) { - return <>; + const { onChange, selectedLayout } = props; + + return ( + + ); } 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..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 @@ -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; @@ -124,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]; 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..da4b8a0c02d --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx @@ -0,0 +1,289 @@ +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 (computed once per render) + const canCreateIssues = useMemo(() => { + 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 + // 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: 500 }, + 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 = 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(response.total_count ?? 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..4bcfab6340c --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx @@ -0,0 +1,283 @@ +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"; +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 (computed once per render) + const canCreateIssues = useMemo(() => { + 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; + + await removeIssue(draggedIssue.project_id, draggedIssueId).finally(() => { + setDeleteIssueModal(false); + setDraggedIssueId(undefined); + }); + }; + + const handleCollapsedGroups = useCallback( + (toggle: "group_by" | "sub_group_by", value: string) => { + if (workspaceSlug) { + 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, + }); + } + }, + [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..eaeba30174b --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx @@ -0,0 +1,217 @@ +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); + + // 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 { ...rest, state_id: targetState.id } as Partial; + } + + return rest as Partial; + }, [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..3ee9c469192 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,81 @@ 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; + 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) { + updatedIssue = { ...updatedIssue, state_id: targetState.id }; + issueUpdates["state_id"] = { + ADD: [targetState.id], + REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], + }; + } else { + 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; + 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) { + updatedIssue = { ...updatedIssue, state_id: targetState.id }; + issueUpdates["state_id"] = { + ADD: [targetState.id], + REMOVE: sourceIssue.state_id ? [sourceIssue.state_id] : [], + }; + } else { + 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..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; @@ -685,18 +685,20 @@ 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] ); 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( @@ -720,11 +722,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 +751,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 +812,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..8491dca119f 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,33 @@ 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"; + } + // 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) { + _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 +291,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 +361,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: {