From a308e80968c57beaa07a882138c5d5e5a3058b65 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:08:30 -0700 Subject: [PATCH] fix(board): enforce explicit workspace status limit --- .../WorkspaceKanbanSettingsMenu.test.tsx | 55 +++++++++++++++++-- .../sidebar/WorkspaceKanbanSettingsMenu.tsx | 9 +++ src/shared/workspace-statuses.test.ts | 20 +++---- src/shared/workspace-statuses.ts | 9 +-- 4 files changed, 71 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx index 35e9f8a0c92..36a2d63b551 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.test.tsx @@ -3,6 +3,7 @@ import { createRoot, type Root } from 'react-dom/client' import { act, type ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import { MAX_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' const statuses: WorkspaceStatusDefinition[] = [{ id: 'todo', label: 'Todo' }] ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -25,14 +26,22 @@ import WorkspaceKanbanSettingsMenu from './WorkspaceKanbanSettingsMenu' let root: Root | null = null let container: HTMLDivElement | null = null -function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void { +function renderMenu({ + workspaceStatuses = statuses, + onSyncTaskStatusFromWorkspaceBoardChange = vi.fn<(enabled: boolean) => void>(), + onAddStatus = vi.fn<() => void>() +}: { + workspaceStatuses?: WorkspaceStatusDefinition[] + onSyncTaskStatusFromWorkspaceBoardChange?: (enabled: boolean) => void + onAddStatus?: () => void +} = {}): void { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root?.render( ) }) @@ -59,7 +68,7 @@ afterEach(() => { describe('WorkspaceKanbanSettingsMenu', () => { it('renders the task status sync switch and forwards changes', async () => { const onChange = vi.fn() - renderMenu(onChange) + renderMenu({ onSyncTaskStatusFromWorkspaceBoardChange: onChange }) const toggle = document.querySelector( 'button[role="switch"][aria-label="Sync board and issue status"]' @@ -74,4 +83,42 @@ describe('WorkspaceKanbanSettingsMenu', () => { expect(onChange).toHaveBeenCalledWith(true) }) + + it('adds another status below the board limit', () => { + const onAddStatus = vi.fn() + renderMenu({ onAddStatus }) + + const addStatus = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Add status' + ) + + expect(addStatus?.disabled).toBe(false) + addStatus?.click() + expect(onAddStatus).toHaveBeenCalledOnce() + }) + + it('disables adding another status at the explicit board limit', () => { + const onAddStatus = vi.fn() + renderMenu({ + workspaceStatuses: Array.from({ length: MAX_WORKSPACE_STATUSES }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` + })), + onAddStatus + }) + + const addStatus = Array.from(document.querySelectorAll('button')).find( + (button) => button.textContent?.trim().startsWith('Add status') === true + ) + + expect(document.body.textContent).toContain( + `${MAX_WORKSPACE_STATUSES} / ${MAX_WORKSPACE_STATUSES}` + ) + expect(addStatus?.textContent).toContain( + `${MAX_WORKSPACE_STATUSES} / ${MAX_WORKSPACE_STATUSES}` + ) + expect(addStatus?.disabled).toBe(true) + addStatus?.click() + expect(onAddStatus).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx index a7fd4693143..9ec24a3d04d 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSettingsMenu.tsx @@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { cn } from '@/lib/utils' import { SettingsSwitch } from '../settings/SettingsFormControls' import type { WorkspaceStatusDefinition } from '../../../../shared/types' +import { MAX_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses' import { getWorkspaceStatusVisualMeta } from './workspace-status' import WorkspaceStatusAppearancePopover from './WorkspaceStatusAppearancePopover' import { translate } from '@/i18n/i18n' @@ -38,6 +39,8 @@ export default function WorkspaceKanbanSettingsMenu({ onRemoveStatus, onAddStatus }: WorkspaceKanbanSettingsMenuProps): React.JSX.Element { + const statusLimitReached = workspaceStatuses.length >= MAX_WORKSPACE_STATUSES + return ( @@ -195,6 +198,7 @@ export default function WorkspaceKanbanSettingsMenu({ variant="ghost" size="xs" className="mt-1 h-7 w-full justify-start text-[12px]" + disabled={statusLimitReached} onClick={onAddStatus} > @@ -202,6 +206,11 @@ export default function WorkspaceKanbanSettingsMenu({ 'auto.components.sidebar.WorkspaceKanbanSettingsMenu.79eb990aa4', 'Add status' )} + {statusLimitReached ? ( + + {workspaceStatuses.length} / {MAX_WORKSPACE_STATUSES} + + ) : null} diff --git a/src/shared/workspace-statuses.test.ts b/src/shared/workspace-statuses.test.ts index 2c46a41aae7..236501b9096 100644 --- a/src/shared/workspace-statuses.test.ts +++ b/src/shared/workspace-statuses.test.ts @@ -3,6 +3,7 @@ import { WORKSPACE_BOARD_COLUMN_WIDTH_DEFAULT, WORKSPACE_BOARD_COLUMN_WIDTH_MAX, WORKSPACE_BOARD_COLUMN_WIDTH_MIN, + MAX_WORKSPACE_STATUSES, clampWorkspaceBoardColumnWidth, cloneDefaultWorkspaceStatuses, normalizePersistedWorkspaceStatuses, @@ -10,28 +11,25 @@ import { } from './workspace-statuses' describe('workspace status visuals', () => { - it('keeps workflows longer than a dozen columns', () => { - // Why: mirroring an external workflow (Jira, Linear) routinely needs more - // than a dozen states. The old cap of 12 dropped the extra ones on the - // next normalize, with no error and nothing in the UI preventing them. - const authored = Array.from({ length: 20 }, (_, index) => ({ - id: `state-${index}`, - label: `State ${index}` + it.each([13, 20])('keeps all %i authored columns in order', (count) => { + const authored = Array.from({ length: count }, (_, index) => ({ + id: `state-${index + 1}`, + label: `State ${index + 1}` })) const statuses = normalizeWorkspaceStatuses(authored) - expect(statuses).toHaveLength(20) - expect(statuses.at(-1)).toMatchObject({ id: 'state-19', label: 'State 19' }) + expect(statuses).toHaveLength(count) + expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id)) }) - it('still truncates absurd payloads so corrupted data cannot explode the board', () => { + it('clamps corrupted payloads to the exact board limit', () => { const corrupted = Array.from({ length: 500 }, (_, index) => ({ id: `state-${index}`, label: `State ${index}` })) - expect(normalizeWorkspaceStatuses(corrupted).length).toBeLessThanOrEqual(64) + expect(normalizeWorkspaceStatuses(corrupted)).toHaveLength(MAX_WORKSPACE_STATUSES) }) it('keeps the default workflow order', () => { diff --git a/src/shared/workspace-statuses.ts b/src/shared/workspace-statuses.ts index 245ebee1ab6..74acb58bbe5 100644 --- a/src/shared/workspace-statuses.ts +++ b/src/shared/workspace-statuses.ts @@ -9,13 +9,8 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults' const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:' const MAX_STATUS_LABEL_LENGTH = 32 -// Why: corruption guard only — nothing in the board or the sidebar depends on -// this number, and the renderer never enforces it, so a low cap just truncated -// legitimate columns on the next normalize. Teams mirroring an external -// workflow (Jira, Linear) routinely need more than a dozen states; the board -// already scrolls horizontally and groups by status, so the extra columns cost -// nothing structurally. -const MAX_WORKSPACE_STATUSES = 64 +// Bounds corrupted payloads and the board's eager lane rendering. +export const MAX_WORKSPACE_STATUSES = 64 type WorkspaceStatusNormalizationOptions = { migrateDefaultWorkflowStatuses?: boolean migrateLegacyDefaultStatusVisuals?: boolean