mirror of
https://github.com/stablyai/orca.git
synced 2026-10-29 00:03:37 +00:00
fix(board): enforce explicit workspace status limit
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user