-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Implementation of the feature #6642 Calendar/Kanban View for Individu… #8588
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Implementation of the feature #6642 Calendar/Kanban View for Individu… #8588
Conversation
… Individual User across the Projects
📝 WalkthroughWalkthroughThis PR implements workspace-level calendar and kanban views for individual users across projects. It adds grouped pagination support to the backend API, introduces new workspace root components for calendar and kanban layouts, consolidates filter store types across calendar components, and provides utilities for state grouping and drag-drop interactions. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant WorkspaceCalendarRoot
participant IssueStore
participant WorkspaceService
participant CalendarChart
participant DragDropHandler
User->>WorkspaceCalendarRoot: Load workspace calendar view
WorkspaceCalendarRoot->>IssueStore: Fetch calendar issues by target_date
IssueStore->>WorkspaceService: GET issues (grouped by target_date)
WorkspaceService-->>IssueStore: Return grouped issues
WorkspaceCalendarRoot->>IssueStore: Fetch no-date issues separately
IssueStore->>WorkspaceService: GET issues (target_date__isnull=true)
WorkspaceService-->>IssueStore: Return no-date issues
WorkspaceCalendarRoot->>CalendarChart: Render with grouped data + no-date section
CalendarChart-->>User: Display calendar with date-grouped issues
User->>DragDropHandler: Drag issue to different date
DragDropHandler->>WorkspaceCalendarRoot: handleDragDrop(issue, targetDate)
WorkspaceCalendarRoot->>IssueStore: Update issue.target_date
IssueStore->>WorkspaceService: PATCH issue with new target_date
WorkspaceService-->>IssueStore: Confirm update
IssueStore-->>CalendarChart: Update UI reactively
sequenceDiagram
participant User
participant WorkspaceKanBanRoot
participant IssueStore
participant WorkspaceService
participant KanBanComponent
participant DeleteDropHandler
User->>WorkspaceKanBanRoot: Load workspace kanban view
WorkspaceKanBanRoot->>IssueStore: Fetch issues (grouped by state_detail.group)
IssueStore->>WorkspaceService: GET issues (group_by=state_detail.group)
WorkspaceService-->>IssueStore: Return grouped issues
WorkspaceKanBanRoot->>KanBanComponent: Render with grouped data & group-collapse state
KanBanComponent-->>User: Display kanban with state groups
User->>KanBanComponent: Drag issue to delete drop zone
KanBanComponent->>DeleteDropHandler: onDrop(issue)
DeleteDropHandler->>User: Show DeleteIssueModal
User->>DeleteDropHandler: Confirm delete
DeleteDropHandler->>IssueStore: Remove issue
IssueStore->>WorkspaceService: DELETE issue
WorkspaceService-->>IssueStore: Confirm deletion
IssueStore-->>KanBanComponent: Update UI, remove card
sequenceDiagram
participant User
participant WorkspaceQuickAddIssueRoot
participant ProjectDropdown
participant QuickAddForm
participant WorkspaceService
participant IssueStore
participant Toast
User->>WorkspaceQuickAddIssueRoot: Click quick-add trigger
WorkspaceQuickAddIssueRoot->>WorkspaceQuickAddIssueRoot: Set isOpen=true, select first joined project
WorkspaceQuickAddIssueRoot->>ProjectDropdown: Display project selector
User->>ProjectDropdown: Select project
WorkspaceQuickAddIssueRoot->>WorkspaceService: getProjectStates(selectedProject)
WorkspaceService-->>WorkspaceQuickAddIssueRoot: Return states
WorkspaceQuickAddIssueRoot->>QuickAddForm: Resolve state_detail.group to state_id via findStateByGroup
User->>QuickAddForm: Fill form & submit
WorkspaceQuickAddIssueRoot->>WorkspaceService: createIssue(projectId, issuePayload)
WorkspaceService-->>WorkspaceQuickAddIssueRoot: Return created issue
WorkspaceQuickAddIssueRoot->>IssueStore: addIssuesToMap(issue)
WorkspaceQuickAddIssueRoot->>Toast: Show success with CreateIssueToastActionItems
Toast-->>User: Success notification with action items
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes The PR introduces multiple substantial new components (calendar/kanban workspace roots with 287+289 lines of intricate logic involving permissions, drag-drop, and state management), consolidates type systems across numerous files, extends stores and hooks with new methods, and includes significant utilities for state grouping. The heterogeneity—spanning backend pagination, frontend components, stores, hooks, and utilities—combined with logic density in the workspace root components necessitates detailed review of interactions across multiple systems. Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/web/core/hooks/use-issues-actions.tsx (1)
687-699: Keep pagination viewId consistent with fetchIssues override.
fetchIssuesnow accepts an explicitviewId, butfetchNextIssuesstill uses the routerglobalViewId. If callers pass a viewId (workspace-level views), pagination can no-op or page the wrong list. Consider mirroring the sameeffectiveViewIdlogic infetchNextIssues.💡 Suggested fix
- const fetchNextIssues = useCallback( - async (groupId?: string, subGroupId?: string) => { - if (!workspaceSlug || !globalViewId) return; - return issues.fetchNextIssues(workspaceSlug.toString(), globalViewId.toString(), groupId, subGroupId); - }, - [issues.fetchIssues, workspaceSlug, globalViewId] - ); + const fetchNextIssues = useCallback( + 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] + );- fetchNextIssues: (groupId?: string, subGroupId?: string) => Promise<TIssuesResponse | undefined>; + fetchNextIssues: (groupId?: string, subGroupId?: string, viewId?: string) => Promise<TIssuesResponse | undefined>;apps/web/core/services/workspace.service.ts (1)
266-283: Fix type annotation inworkspace-root.tsxforgetViewIssuesresponse.The
getViewIssuesmethod now returnsPromise<TIssuesResponse | undefined>for canceled requests, butworkspace-root.tsxline 133 declares the response asconst response: TIssuesResponse(missing| undefined). This will cause type errors in strict mode. Update the type toTIssuesResponse | undefinedor remove the explicit type annotation to infer it correctly. The runtime guardif (response && response.results)is present but the type declaration is incorrect.apps/web/core/store/issue/workspace/filter.store.ts (1)
248-265: Re-check sub_group_by after normalizing group_by.
If group_by is forced to "state_detail.group", sub_group_by can still equal it, bypassing the earlier guard. Consider validating again after the normalization.🔧 Suggested fix
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"; } + if (_filters.displayFilters.group_by === _filters.displayFilters.sub_group_by) { + _filters.displayFilters.sub_group_by = null; + updatedDisplayFilters.sub_group_by = null; + } }apps/web/core/components/issues/issue-layouts/calendar/day-tile.tsx (1)
77-123: Avoid using ref.currentin useEffect dependency array.
dayTileRef?.currentin the dependency array won't trigger re-renders when the ref changes since refs are mutable and don't cause component updates. The ref object itself (dayTileRef) is stable across renders.Proposed fix
- }, [dayTileRef?.current, formattedDatePayload]); + }, [formattedDatePayload, handleDragAndDrop, issues]);Note: You may also want to include
handleDragAndDropandissuesin the dependency array since they're used inside the effect, or wrap them inuseCallback/memoize appropriately to prevent stale closures.
🤖 Fix all issues with AI agents
In `@apps/api/plane/app/views/view/base.py`:
- Around line 246-247: Replace the unsafe deepcopy of the Django QuerySet:
instead of using copy.deepcopy(issue_queryset) to create
filtered_issue_queryset, call issue_queryset.all() to produce a new, independent
QuerySet (and remove the now-unused import copy from the top of the file if it's
no longer referenced). Ensure this change targets the filtered_issue_queryset
assignment where issue_queryset is referenced.
In
`@apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx`:
- Around line 103-165: The no-date fetch (fetchNoDateIssues) currently uses
perPageCount: 50 and sets setNoDateTotalCount(issueIds.length), which caps and
misreports totals; change it to read TIssuesResponse.total_count for total count
and implement pagination/load-more using the same pattern as the main calendar
(use workspaceService.getViewIssues response.next_page_results / cursors and a
loadMoreNoDateIssues handler or reuse loadMoreIssues) to request additional
pages instead of relying on a single 50-item request; accumulate results by
appending new issues to the existing no-date IDs (setNoDateIssueIds) and calling
addIssuesToMap for each page, and only fall back to client-side filtering of
issue.target_date while preserving the API pagination cursors rather than
truncating by array.length.
In
`@apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx`:
- Around line 205-218: handleCollapsedGroups currently mutates the store-backed
collapsedGroups array with push(), which can cause non-atomic updates; instead
create a new array before calling updateFilters: read the existing array from
issuesFilter?.issueFilters?.kanbanFilters?.[toggle] into collapsedGroups, then
if value is included produce a new array using filter to remove it, otherwise
produce a new array by concatenating the value (e.g., [...collapsedGroups,
value]); pass that new array to updateFilters (function updateFilters) so the
original store array is never mutated in place.
- Around line 190-203: The current handleDeleteIssue swallows errors by catching
all exceptions and always resolving, preventing DeleteIssueModal from receiving
rejections and showing error toasts; update handleDeleteIssue so that you await
removeIssue(draggedIssue.project_id, draggedIssueId) and only call
setDeleteIssueModal(false) and setDraggedIssueId(undefined) on success, but do
not swallow failures—either remove the try/catch entirely or rethrow the caught
error in the catch block (keep references to handleDeleteIssue, removeIssue,
setDeleteIssueModal, setDraggedIssueId, and DeleteIssueModal to locate the
change).
In `@apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx`:
- Around line 69-87: The logic in resolvedPrePopulatedData (useMemo) currently
retains "state_detail.group" when no matching state is found; update it so you
always remove the "state_detail.group" key from prePopulatedData and only add
state_id when findStateByGroup(projectStates, stateGroup) returns a targetState;
locate the resolution in resolvedPrePopulatedData (references:
selectedProjectId, prePopulatedData, getProjectStates, findStateByGroup, TIssue)
and return the spread rest without the "state_detail.group" field in both
branches, conditionally merging state_id = targetState.id only when targetState
exists.
In `@apps/web/core/components/issues/issue-layouts/utils.tsx`:
- Around line 563-597: When handling groupBy === "state_detail.group", guard
against a missing project state list by checking the result of
getProjectStates(sourceIssue.project_id) and if projectStates is undefined (i.e.
sourceIssue.project_id is falsy or no states returned) throw an explicit Error
(or otherwise block the drop) before calling findStateByGroup; update the logic
around getProjectStates, findStateByGroup, updatedIssue and issueUpdates to
ensure you only set state_id and issueUpdates when a valid targetState is found
and otherwise reject the operation with a clear error message.
🧹 Nitpick comments (5)
apps/web/ce/components/views/helper.tsx (1)
8-12: UnusedworkspaceSlugprop inGlobalViewLayoutSelection.The
workspaceSlugproperty is defined inTLayoutSelectionPropsbut is not destructured or used in the component implementation. If this is intentional for API consistency, consider adding a comment. Otherwise, remove it from the type definition to keep the interface clean.♻️ Suggested fix if the prop is not needed
export type TLayoutSelectionProps = { onChange: (layout: EIssueLayoutTypes) => void; selectedLayout: EIssueLayoutTypes; - workspaceSlug: string; };Also applies to: 21-22
apps/api/plane/app/views/view/base.py (1)
266-339: Add validation for allowedgroup_byandsub_group_byfield values.The code correctly prevents
group_byandsub_group_byfrom being equal, but does not validate that these values are from the set of supported grouping fields (state_id,priority,state__group,cycle_id,project_id,labels__id,assignees__id,issue_module__module_id,target_date,start_date,created_by). Invalid field names are silently ignored—issue_group_valuesreturns an empty list and results fail to group properly—leaving users without feedback that their grouping parameter was unsupported. Adding validation to reject invalid field names with a 400 error would improve clarity and user experience.apps/web/core/components/issues/issue-layouts/calendar/calendar.tsx (1)
241-276: Add ARIA state for the “No Date” toggle.This makes the collapsible section discoverable to screen readers.
♿ Proposed accessibility tweak
- <button + <button type="button" + aria-expanded={!isNoDateCollapsed} + aria-controls="no-date-section" className="flex w-full items-center gap-2 px-4 py-2 bg-layer-1 cursor-pointer hover:bg-layer-2 text-left" onClick={() => setIsNoDateCollapsed(!isNoDateCollapsed)} > <ChevronRight className={cn("size-4 text-tertiary transition-transform", { "rotate-90": !isNoDateCollapsed, })} /> <span className="text-13 font-medium text-secondary">No Date</span> <span className="text-11 text-tertiary">({noDateIssueCount ?? noDateIssueIds.length})</span> </button> {!isNoDateCollapsed && ( - <div className="px-4 py-2 bg-surface-1"> + <div id="no-date-section" className="px-4 py-2 bg-surface-1"> <CalendarIssueBlocks date={new Date()} issueIdList={noDateIssueIds} loadMoreIssues={() => {}}apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx (1)
81-92: Avoid duplicate permission scans per render.Compute once and reuse for the two prop expressions.
♻️ Proposed small refactor
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]); + const canCreateIssuesValue = canCreateIssues(); ... - enableQuickIssueCreate={enableQuickAdd && canCreateIssues()} + enableQuickIssueCreate={enableQuickAdd && canCreateIssuesValue} ... - disableIssueCreation={!enableIssueCreation || !canCreateIssues()} + disableIssueCreation={!enableIssueCreation || !canCreateIssuesValue}Also applies to: 223-223, 270-273
apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx (1)
51-62: ComputecanCreateIssuesonce per render.Avoid repeating the permission scan for both creation props.
♻️ Small reuse improvement
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]); + const canCreateIssuesValue = canCreateIssues(); ... - enableQuickIssueCreate={enableQuickAdd && canCreateIssues()} - disableIssueCreation={!enableIssueCreation || !canCreateIssues()} + enableQuickIssueCreate={enableQuickAdd && canCreateIssuesValue} + disableIssueCreation={!enableIssueCreation || !canCreateIssuesValue}Also applies to: 274-275
| # Keeping a copy of the queryset before applying annotations (for counts) | ||
| filtered_issue_queryset = copy.deepcopy(issue_queryset) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid copy.deepcopy on Django QuerySets.
Django QuerySets have internal state (database connections, caches, prefetch state) that may not clone correctly with deepcopy. The idiomatic way to clone a queryset is using .all(), which returns a new QuerySet with the same filters but independent evaluation.
Proposed fix
- # Keeping a copy of the queryset before applying annotations (for counts)
- filtered_issue_queryset = copy.deepcopy(issue_queryset)
+ # Keeping a copy of the queryset before applying annotations (for counts)
+ filtered_issue_queryset = issue_queryset.all()If copy is no longer used elsewhere in this file, you can also remove the import copy at line 1.
🤖 Prompt for AI Agents
In `@apps/api/plane/app/views/view/base.py` around lines 246 - 247, Replace the
unsafe deepcopy of the Django QuerySet: instead of using
copy.deepcopy(issue_queryset) to create filtered_issue_queryset, call
issue_queryset.all() to produce a new, independent QuerySet (and remove the
now-unused import copy from the top of the file if it's no longer referenced).
Ensure this change targets the filtered_issue_queryset assignment where
issue_queryset is referenced.
| // 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<string, unknown>; | ||
| // 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]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find TIssuesResponse definition
rg -n "interface TIssuesResponse|type TIssuesResponse" --max-count=5Repository: makeplane/plane
Length of output: 197
🏁 Script executed:
# Check the workspaceService.getViewIssues method signature
rg -n "getViewIssues" -A 10 | head -60Repository: makeplane/plane
Length of output: 4777
🏁 Script executed:
# Look for how pagination is handled in other parts of the calendar or issues
rg -n "perPageCount" apps/web/core/components/issues --max-count=10Repository: makeplane/plane
Length of output: 1260
🏁 Script executed:
# Search for total or count field usage in API responses
rg -n "\.total|\.count|totalCount" apps/web/core/components/issues/issue-layouts/calendar -B 2 -A 2Repository: makeplane/plane
Length of output: 41
🏁 Script executed:
# Read the TIssuesResponse type definition
cat packages/types/src/issues/issue.ts | head -150 | tail -50Repository: makeplane/plane
Length of output: 1044
🏁 Script executed:
# Get more context around line 120
sed -n '110,140p' packages/types/src/issues/issue.tsRepository: makeplane/plane
Length of output: 701
🏁 Script executed:
# Check how setNoDateTotalCount is used elsewhere
rg -n "setNoDateTotalCount|NoDateTotalCount" apps/web/core/components/issues/issue-layouts/calendar/Repository: makeplane/plane
Length of output: 582
🏁 Script executed:
# Look for load-more or pagination patterns in the calendar component
rg -n "load.*more|loadMore|pagination|offset|page" apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx -iRepository: makeplane/plane
Length of output: 511
🏁 Script executed:
# Check how noDateTotalCount and noDateIssueIds are used in the render/display
sed -n '1,300p' apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx | grep -n "noDate\|NoDate" -C 2Repository: makeplane/plane
Length of output: 1373
🏁 Script executed:
# Look at the return/render section to see how no-date issues are displayed
tail -100 apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsxRepository: makeplane/plane
Length of output: 3439
🏁 Script executed:
# Check if loadMoreIssues is called for no-date issues anywhere
rg -n "loadMoreIssues|noDateIssueIds" apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx -B 2 -A 2Repository: makeplane/plane
Length of output: 751
🏁 Script executed:
# Check the CalendarChart component to see how it handles noDateIssueIds
rg -n "noDateIssueIds\|noDateIssueCount" apps/web/core/components/issues/issue-layouts/calendar -B 3 -A 3Repository: makeplane/plane
Length of output: 41
🏁 Script executed:
# Verify if response.total_count is available and could be used
sed -n '114,165p' apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsxRepository: makeplane/plane
Length of output: 2180
🏁 Script executed:
# Check how total_count is used in other parts of the codebase for pagination
rg -n "response\.total_count\|response\.total_pages\|response\.next_page_results" apps/web/core/components/issues --max-count=10Repository: makeplane/plane
Length of output: 41
🏁 Script executed:
# Check the CalendarChart component signature to understand how noDateIssueCount is used
rg -n "interface.*CalendarChart|type.*CalendarChart" -A 30 apps/web/core/components/issues/issue-layouts/calendar/ | head -100Repository: makeplane/plane
Length of output: 41
🏁 Script executed:
# Look for how pagination works for the main calendar issues
sed -n '70,110p' apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsxRepository: makeplane/plane
Length of output: 1902
Use API's total_count field and implement pagination for no-date issues.
The no-date list is capped at perPageCount: 50 with setNoDateTotalCount(issueIds.length), so additional issues are hidden and the count is misreported when there are more than 50 no-date issues. The TIssuesResponse already includes total_count, next_page_results, and pagination cursors. Implement load-more logic similar to the main calendar (using loadMoreIssues pattern) to fetch and display all no-date issues, or remove the perPageCount limit if pagination isn't needed.
🤖 Prompt for AI Agents
In
`@apps/web/core/components/issues/issue-layouts/calendar/roots/workspace-root.tsx`
around lines 103 - 165, The no-date fetch (fetchNoDateIssues) currently uses
perPageCount: 50 and sets setNoDateTotalCount(issueIds.length), which caps and
misreports totals; change it to read TIssuesResponse.total_count for total count
and implement pagination/load-more using the same pattern as the main calendar
(use workspaceService.getViewIssues response.next_page_results / cursors and a
loadMoreNoDateIssues handler or reuse loadMoreIssues) to request additional
pages instead of relying on a single 50-item request; accumulate results by
appending new issues to the existing no-date IDs (setNoDateIssueIds) and calling
addIssuesToMap for each page, and only fall back to client-side filtering of
issue.target_date while preserving the API pagination cursors rather than
truncating by array.length.
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don’t swallow delete failures—let the modal surface errors.
DeleteIssueModal relies on a rejected promise to show error toasts; the current try/catch always resolves.
🐛 Propagate delete errors
const handleDeleteIssue = async () => {
const draggedIssue = getIssueById(draggedIssueId ?? "");
if (!draggedIssueId || !draggedIssue) return;
try {
await removeIssue(draggedIssue.project_id, draggedIssueId);
setDeleteIssueModal(false);
setDraggedIssueId(undefined);
- } catch (_error) {
+ } catch (error) {
setDeleteIssueModal(false);
setDraggedIssueId(undefined);
+ throw error;
}
};🤖 Prompt for AI Agents
In
`@apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx`
around lines 190 - 203, The current handleDeleteIssue swallows errors by
catching all exceptions and always resolving, preventing DeleteIssueModal from
receiving rejections and showing error toasts; update handleDeleteIssue so that
you await removeIssue(draggedIssue.project_id, draggedIssueId) and only call
setDeleteIssueModal(false) and setDraggedIssueId(undefined) on success, but do
not swallow failures—either remove the try/catch entirely or rethrow the caught
error in the catch block (keep references to handleDeleteIssue, removeIssue,
setDeleteIssueModal, setDraggedIssueId, and DeleteIssueModal to locate the
change).
| 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid mutating the collapsedGroups array in place.
push() mutates the store‑backed array before updateFilters runs; use a new array to keep updates atomic.
✅ Safer array update
- let collapsedGroups = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || [];
- if (collapsedGroups.includes(value)) {
- collapsedGroups = collapsedGroups.filter((_value) => _value != value);
- } else {
- collapsedGroups.push(value);
- }
+ const current = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] ?? [];
+ const collapsedGroups = current.includes(value)
+ ? current.filter((_value) => _value !== value)
+ : [...current, value];
// projectId is not used for workspace-level filters
void updateFilters("", EIssueFilterType.KANBAN_FILTERS, {
[toggle]: collapsedGroups,
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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, | |
| }); | |
| } | |
| const handleCollapsedGroups = useCallback( | |
| (toggle: "group_by" | "sub_group_by", value: string) => { | |
| if (workspaceSlug) { | |
| const current = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] ?? []; | |
| const collapsedGroups = current.includes(value) | |
| ? current.filter((_value) => _value !== value) | |
| : [...current, value]; | |
| // projectId is not used for workspace-level filters | |
| void updateFilters("", EIssueFilterType.KANBAN_FILTERS, { | |
| [toggle]: collapsedGroups, | |
| }); | |
| } |
🤖 Prompt for AI Agents
In
`@apps/web/core/components/issues/issue-layouts/kanban/roots/workspace-root.tsx`
around lines 205 - 218, handleCollapsedGroups currently mutates the store-backed
collapsedGroups array with push(), which can cause non-atomic updates; instead
create a new array before calling updateFilters: read the existing array from
issuesFilter?.issueFilters?.kanbanFilters?.[toggle] into collapsedGroups, then
if value is included produce a new array using filter to remove it, otherwise
produce a new array by concatenating the value (e.g., [...collapsedGroups,
value]); pass that new array to updateFilters (function updateFilters) so the
original store array is never mutated in place.
| // 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<string, unknown>)["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<string, unknown>; | ||
| return { ...rest, state_id: targetState.id } as Partial<TIssue>; | ||
| } | ||
|
|
||
| return prePopulatedData; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strip state_detail.group even if no state match is found.
If a project lacks a state in that group, the payload keeps "state_detail.group", which may be rejected by the API. Consider always removing that key and only adding state_id when resolved.
🔧 Suggested fix
- if (targetState) {
- // Return prePopulatedData with state_id set and state_detail.group removed
- const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record<string, unknown>;
- return { ...rest, state_id: targetState.id } as Partial<TIssue>;
- }
-
- return prePopulatedData;
+ // Always remove state_detail.group once it's been consumed
+ const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record<string, unknown>;
+ if (targetState) {
+ return { ...rest, state_id: targetState.id } as Partial<TIssue>;
+ }
+
+ return rest as Partial<TIssue>;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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<string, unknown>)["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<string, unknown>; | |
| return { ...rest, state_id: targetState.id } as Partial<TIssue>; | |
| } | |
| return prePopulatedData; | |
| // 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<string, unknown>)["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 remove state_detail.group once it's been consumed | |
| const { "state_detail.group": _removed, ...rest } = prePopulatedData as Record<string, unknown>; | |
| if (targetState) { | |
| return { ...rest, state_id: targetState.id } as Partial<TIssue>; | |
| } | |
| return rest as Partial<TIssue>; |
🤖 Prompt for AI Agents
In `@apps/web/core/components/issues/issue-layouts/quick-add/workspace-root.tsx`
around lines 69 - 87, The logic in resolvedPrePopulatedData (useMemo) currently
retains "state_detail.group" when no matching state is found; update it so you
always remove the "state_detail.group" key from prePopulatedData and only add
state_id when findStateByGroup(projectStates, stateGroup) returns a targetState;
locate the resolution in resolvedPrePopulatedData (references:
selectedProjectId, prePopulatedData, getProjectStates, findStateByGroup, TIssue)
and return the spread rest without the "state_detail.group" field in both
branches, conditionally merging state_id = targetState.id only when targetState
exists.
| // 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 }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find the findStateByGroup function definition
rg "findStateByGroup" --type ts --type tsx -B 2 -A 10Repository: makeplane/plane
Length of output: 86
🏁 Script executed:
# Look at the broader context of the file to understand error handling patterns
fd "utils.tsx" | grep -E "issue-layouts"Repository: makeplane/plane
Length of output: 226
🏁 Script executed:
# Check how sourceIssue.project_id is validated before reaching this code
rg "sourceIssue.project_id" --type ts --type tsx -B 3 -A 3Repository: makeplane/plane
Length of output: 86
🏁 Script executed:
# Search for store.state.getProjectStates usage elsewhere
rg "getProjectStates" --type ts --type tsx -B 2 -A 5Repository: makeplane/plane
Length of output: 86
🏁 Script executed:
# Search for findStateByGroup function with correct file type
rg "findStateByGroup" -t typescript -B 2 -A 10Repository: makeplane/plane
Length of output: 93
🏁 Script executed:
# Read the specific file to see the exact code and context
cat -n apps/web/core/components/issues/issue-layouts/utils.tsx | sed -n '550,610p'Repository: makeplane/plane
Length of output: 3256
🏁 Script executed:
# Search for getProjectStates calls to understand usage patterns
rg "getProjectStates" -t typescript -B 2 -A 5Repository: makeplane/plane
Length of output: 93
🏁 Script executed:
# Check how this function is called to see if project_id is guaranteed
rg "handleDragEnd|updateDraggedIssue" -t typescript -B 3 -A 3Repository: makeplane/plane
Length of output: 93
🏁 Script executed:
# Search for findStateByGroup definition without type flag
rg "function findStateByGroup|const findStateByGroup|export.*findStateByGroup" -B 2 -A 8Repository: makeplane/plane
Length of output: 1056
🏁 Script executed:
# Look for where sourceIssue comes from and if project_id is validated
rg "sourceIssue\s*=" apps/web/core/components/issues/issue-layouts/utils.tsx -B 5 -A 2Repository: makeplane/plane
Length of output: 311
🏁 Script executed:
# Check the function signature that contains this code
head -n 570 apps/web/core/components/issues/issue-layouts/utils.tsx | tail -n 100 | grep -E "^(const|function|export)" | tail -1Repository: makeplane/plane
Length of output: 101
🏁 Script executed:
# Search for findStateByGroup in the entire file
grep -n "findStateByGroup" apps/web/core/components/issues/issue-layouts/utils.tsxRepository: makeplane/plane
Length of output: 266
Guard against missing project states during state-group DnD.
When sourceIssue.project_id is falsy, projectStates becomes undefined, causing findStateByGroup to return undefined and the drop to proceed silently without updating state_id. The issue will revert to its original state after refresh. Add an explicit check and error when projectStates is undefined, or block the drop for state-group operations missing project context.
🤖 Prompt for AI Agents
In `@apps/web/core/components/issues/issue-layouts/utils.tsx` around lines 563 -
597, When handling groupBy === "state_detail.group", guard against a missing
project state list by checking the result of
getProjectStates(sourceIssue.project_id) and if projectStates is undefined (i.e.
sourceIssue.project_id is falsy or no states returned) throw an explicit Error
(or otherwise block the drop) before calling findStateByGroup; update the logic
around getProjectStates, findStateByGroup, updatedIssue and issueUpdates to
ensure you only set state_id and issueUpdates when a valid targetState is found
and otherwise reject the operation with a clear error message.
Pull Request: Calendar and Kanban Layouts for Workspace Views
Summary
This PR adds Calendar and Kanban layout support to workspace-level views in Plane. Previously, workspace views (
All Issues,Assigned,Created,Subscribed) only supported the Spreadsheet layout. Users can now visualize their work across all projects in time-based (Calendar) and workflow-based (Kanban) formats.Key Features
Changes Overview
Frontend (
apps/web)New Components
calendar/roots/workspace-root.tsxkanban/roots/workspace-root.tsxquick-add/workspace-root.tsxModified Components
calendar/calendar.tsxroots/all-issue-layout-root.tsxutils.tsxfindStateByGroup()utility for cross-project state mapping, enhanced drag-drop handling forstate_detail.groupissue-layout-HOC.tsxStore Changes
workspace/filter.store.tsstate_detail.groupas default group_by for Kanban, layout switch handlingworkspace/issue.store.tshelpers/base-issues.store.tsaddIssue()method exposure for issue map updatesHooks & Services
use-issues.tsaddIssuesToMapfunction for adding fetched issues to the storeworkspace.service.tsBackend (
apps/api)API Changes
views/view/base.pyWorkspaceViewIssuesViewSet, backward-compatible with existing clientsutils/filters/filterset.pytarget_date__isnullandstart_date__isnullfilters for "No Date" queriesPackages
Constants (
packages/constants)issue/filter.tsWORKSPACE_KANBAN_GROUP_BY_OPTIONS, updated layout configs formy_issuesfilter typeissue/common.ts"calendar"toWORKSPACE_ACTIVE_LAYOUTSTechnical Implementation Details
1. State Group to State ID Mapping
Workspace views group issues by
state_detail.group(e.g., "backlog", "started", "completed") instead of specificstate_idbecause states are project-specific. When an issue is dragged to a new state group column:findStateByGroup()utility finds a matching state in the issue's projectstate_idto the resolved state2. "No Date" Section Architecture
The Calendar view fetches issues without
target_dateseparately from date-range issues:target_date__isnull=truefilternoDateIssueIdsandnoDateTotalCountappliedFiltersKeychanges3. Grouped Pagination for Kanban
The backend
WorkspaceViewIssuesViewSetnow supports grouped pagination:group_by: Returns flat list (backward compatible)group_by: Returns grouped structure with per-group paginationGroupedOffsetPaginatorfrom project issue views4. Quick Add with Project Selection
Workspace views require project selection before creating an issue:
API Changes
WorkspaceViewIssuesViewSet
Endpoint:
GET /api/v1/workspaces/{workspace_slug}/views/issues/New Query Parameters:
group_bystate_detail.group,priority)sub_group_bytarget_date__isnullBackward Compatibility: ✅ Existing clients receive flat list when
group_byis not provided.Testing Checklist
Calendar Layout
Kanban Layout
Cross-cutting
Related Issues
Migration Notes
No database migrations required. The feature uses existing issue fields and adds optional query parameters to existing endpoints.
Rollback Plan
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Improvements
✏️ Tip: You can customize this high-level summary in your review settings.