fix(board): enforce explicit workspace status limit

This commit is contained in:
OrcaWin
2026-07-30 23:02:10 -07:00
parent 8fb76a22c0
commit a308e80968
4 changed files with 71 additions and 22 deletions
@@ -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(
<WorkspaceKanbanSettingsMenu
workspaceStatuses={statuses}
workspaceStatuses={workspaceStatuses}
syncTaskStatusFromWorkspaceBoard={false}
onSyncTaskStatusFromWorkspaceBoardChange={onSyncTaskStatusFromWorkspaceBoardChange}
onRenameStatus={vi.fn()}
@@ -40,7 +49,7 @@ function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void {
onChangeStatusIcon={vi.fn()}
onMoveStatus={vi.fn()}
onRemoveStatus={vi.fn()}
onAddStatus={vi.fn()}
onAddStatus={onAddStatus}
/>
)
})
@@ -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<HTMLButtonElement>(
'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()
})
})
@@ -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 (
<DropdownMenu modal={false}>
<Tooltip>
@@ -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}
>
<Plus className="size-3.5" />
@@ -202,6 +206,11 @@ export default function WorkspaceKanbanSettingsMenu({
'auto.components.sidebar.WorkspaceKanbanSettingsMenu.79eb990aa4',
'Add status'
)}
{statusLimitReached ? (
<span className="ml-auto font-normal text-muted-foreground">
{workspaceStatuses.length} / {MAX_WORKSPACE_STATUSES}
</span>
) : null}
</Button>
</div>
</DropdownMenuContent>
+9 -11
View File
@@ -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', () => {
+2 -7
View File
@@ -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