fix(board): stop truncating workflows longer than 12 columns (#11605)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
parent
94cf2f1422
commit
ef9e6ab9a8
|
|
@ -6840,6 +6840,46 @@ describe('Store', () => {
|
|||
expect(store.getUI().syncTaskStatusFromWorkspaceBoard).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves workflows above 20 statuses across load, write, and restart', async () => {
|
||||
const imported = Array.from({ length: 21 }, (_, index) => ({
|
||||
id: `state-${index + 1}`,
|
||||
label: `State ${index + 1}`
|
||||
})).toReversed()
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: { workspaceStatuses: imported },
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual(
|
||||
imported.map((status) => status.id)
|
||||
)
|
||||
|
||||
const authored = Array.from({ length: 64 }, (_, index) => ({
|
||||
id: `final-${String(index + 1).padStart(3, '0')}`,
|
||||
label: `Final ${index + 1}`
|
||||
})).toReversed()
|
||||
store.updateUI({ workspaceStatuses: authored })
|
||||
store.flush()
|
||||
|
||||
expect(store.getUI().workspaceStatuses?.map((status) => status.id)).toEqual(
|
||||
authored.map((status) => status.id)
|
||||
)
|
||||
expect(
|
||||
(readDataFile() as PersistedState).ui.workspaceStatuses?.map((status) => status.id)
|
||||
).toEqual(authored.map((status) => status.id))
|
||||
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getUI().workspaceStatuses?.map((status) => status.id)).toEqual(
|
||||
authored.map((status) => status.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('repairs the known-bad reordered default workspace statuses once on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
|
|
|
|||
|
|
@ -899,6 +899,7 @@ export default function WorkspaceKanbanDrawer({
|
|||
className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden scrollbar-sleek"
|
||||
>
|
||||
<WorkspaceKanbanLaneGrid
|
||||
laneScrollerRef={laneScrollerRef}
|
||||
statuses={workspaceStatuses}
|
||||
laneViews={laneViews}
|
||||
laneFullWorktreeIds={laneFullWorktreeIds}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import React, { createRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Repo, WorkspaceStatusDefinition, Worktree } from '../../../../shared/types'
|
||||
|
||||
const virtualWindow = vi.hoisted(() => ({ startIndex: 0, visibleCount: 4 }))
|
||||
const animationFrames = new Map<number, FrameRequestCallback>()
|
||||
let nextAnimationFrameId = 1
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => {
|
||||
const defaultRangeExtractor = (range: {
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
overscan: number
|
||||
count: number
|
||||
}): number[] => {
|
||||
const start = Math.max(0, range.startIndex - range.overscan)
|
||||
const end = Math.min(range.count - 1, range.endIndex + range.overscan)
|
||||
return Array.from({ length: Math.max(0, end - start + 1) }, (_, index) => start + index)
|
||||
}
|
||||
return {
|
||||
defaultRangeExtractor,
|
||||
useVirtualizer: (options: {
|
||||
count: number
|
||||
estimateSize: (index: number) => number
|
||||
getItemKey: (index: number) => string | number
|
||||
gap: number
|
||||
rangeExtractor: (range: {
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
overscan: number
|
||||
count: number
|
||||
}) => number[]
|
||||
}) => {
|
||||
const endIndex = Math.min(
|
||||
options.count - 1,
|
||||
virtualWindow.startIndex + virtualWindow.visibleCount - 1
|
||||
)
|
||||
const indexes =
|
||||
options.count === 0
|
||||
? []
|
||||
: options.rangeExtractor({
|
||||
startIndex: virtualWindow.startIndex,
|
||||
endIndex,
|
||||
overscan: 1,
|
||||
count: options.count
|
||||
})
|
||||
const size = options.estimateSize(0)
|
||||
return {
|
||||
getTotalSize: () => Math.max(0, options.count * size + (options.count - 1) * options.gap),
|
||||
getVirtualItems: () =>
|
||||
indexes.map((index) => ({
|
||||
index,
|
||||
key: options.getItemKey(index),
|
||||
start: index * (size + options.gap)
|
||||
})),
|
||||
measureElement: () => {},
|
||||
measure: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./WorkspaceKanbanStatusLane', () => ({
|
||||
default: ({
|
||||
status,
|
||||
items,
|
||||
renderCards
|
||||
}: {
|
||||
status: WorkspaceStatusDefinition
|
||||
items: readonly Worktree[]
|
||||
renderCards: boolean
|
||||
}) => (
|
||||
<section
|
||||
data-workspace-status={status.id}
|
||||
data-item-count={items.length}
|
||||
data-render-cards={renderCards ? 'true' : 'false'}
|
||||
>
|
||||
<button type="button">{status.label}</button>
|
||||
</section>
|
||||
)
|
||||
}))
|
||||
|
||||
const { default: WorkspaceKanbanLaneGrid } = await import('./WorkspaceKanbanLaneGrid')
|
||||
const { extractWorkspaceKanbanLaneRange } = await import('./workspace-kanban-lane-range')
|
||||
|
||||
const STATUSES = Array.from({ length: 21 }, (_, index) => ({
|
||||
id: `state-${String(index + 1).padStart(2, '0')}`,
|
||||
label: `State ${index + 1}`
|
||||
}))
|
||||
const REPO_MAP = new Map<string, Repo>()
|
||||
|
||||
function makeGrid(): React.JSX.Element {
|
||||
return (
|
||||
<WorkspaceKanbanLaneGrid
|
||||
laneScrollerRef={createRef()}
|
||||
statuses={STATUSES}
|
||||
laneViews={new Map()}
|
||||
laneFullWorktreeIds={new Map()}
|
||||
hasQuery={false}
|
||||
repoMap={REPO_MAP}
|
||||
activeWorktreeId={null}
|
||||
columnWidth={308}
|
||||
isResizingColumn={false}
|
||||
dragOverStatus={null}
|
||||
canCreateWorktree={true}
|
||||
renderCards={true}
|
||||
selectedWorktreeIds={new Set()}
|
||||
selectedWorktrees={[]}
|
||||
onDragOver={() => {}}
|
||||
onDragLeave={() => {}}
|
||||
onDrop={() => {}}
|
||||
onActivate={() => {}}
|
||||
onSelectionGesture={() => false}
|
||||
onContextMenuSelect={() => []}
|
||||
onCreateWorktree={() => {}}
|
||||
onColumnResizeStart={() => {}}
|
||||
onColumnResizeKeyDown={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderGrid(): ReturnType<typeof render> {
|
||||
return render(makeGrid())
|
||||
}
|
||||
|
||||
function mountedStatusIds(container: HTMLElement): string[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[data-workspace-status]')).map(
|
||||
(lane) => lane.dataset.workspaceStatus ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function flushNextAnimationFrame(): void {
|
||||
const next = animationFrames.entries().next().value as [number, FrameRequestCallback] | undefined
|
||||
expect(next).toBeDefined()
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
animationFrames.delete(next[0])
|
||||
act(() => next[1](performance.now()))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames.clear()
|
||||
nextAnimationFrameId = 1
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
const id = nextAnimationFrameId
|
||||
nextAnimationFrameId += 1
|
||||
animationFrames.set(id, callback)
|
||||
return id
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
|
||||
animationFrames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
virtualWindow.startIndex = 0
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('WorkspaceKanbanLaneGrid', () => {
|
||||
it('reserves the full workflow width while mounting only the horizontal window', () => {
|
||||
const { container } = renderGrid()
|
||||
|
||||
expect(mountedStatusIds(container)).toEqual([
|
||||
'state-01',
|
||||
'state-02',
|
||||
'state-03',
|
||||
'state-04',
|
||||
'state-05'
|
||||
])
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('[data-workspace-board-lane-grid]')?.style.width
|
||||
).toBe(`${21 * 308 + 20 * 12}px`)
|
||||
})
|
||||
|
||||
it('mounts later ordered lanes and releases distant lanes after horizontal scroll', () => {
|
||||
const rendered = renderGrid()
|
||||
virtualWindow.startIndex = 17
|
||||
rendered.rerender(makeGrid())
|
||||
|
||||
expect(mountedStatusIds(rendered.container)).toEqual([
|
||||
'state-17',
|
||||
'state-18',
|
||||
'state-19',
|
||||
'state-20',
|
||||
'state-21'
|
||||
])
|
||||
expect(rendered.container.querySelector('[data-workspace-status="state-01"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps one focused lane mounted without unbounding the virtual window', () => {
|
||||
const rendered = renderGrid()
|
||||
fireEvent.focus(rendered.getByRole('button', { name: 'State 1' }))
|
||||
|
||||
virtualWindow.startIndex = 17
|
||||
rendered.rerender(makeGrid())
|
||||
|
||||
expect(mountedStatusIds(rendered.container)).toEqual([
|
||||
'state-01',
|
||||
'state-17',
|
||||
'state-18',
|
||||
'state-19',
|
||||
'state-20',
|
||||
'state-21'
|
||||
])
|
||||
})
|
||||
|
||||
it('adds only the focused lane to the normal overscanned range', () => {
|
||||
expect(
|
||||
extractWorkspaceKanbanLaneRange({ startIndex: 4, endIndex: 7, overscan: 1, count: 21 }, 18)
|
||||
).toEqual([3, 4, 5, 6, 7, 8, 18])
|
||||
})
|
||||
|
||||
it('hydrates at most one mounted lane per animation frame', () => {
|
||||
const { container } = renderGrid()
|
||||
const renderedLaneCount = (): number =>
|
||||
container.querySelectorAll('[data-render-cards="true"]').length
|
||||
|
||||
expect(renderedLaneCount()).toBe(0)
|
||||
flushNextAnimationFrame()
|
||||
expect(renderedLaneCount()).toBe(1)
|
||||
flushNextAnimationFrame()
|
||||
expect(renderedLaneCount()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,13 @@
|
|||
import React from 'react'
|
||||
import React, {
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useVirtualizer, type Range } from '@tanstack/react-virtual'
|
||||
import type {
|
||||
Repo,
|
||||
WorkspaceStatus,
|
||||
|
|
@ -6,12 +15,17 @@ import type {
|
|||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
|
||||
import { extractWorkspaceKanbanLaneRange } from './workspace-kanban-lane-range'
|
||||
import WorkspaceKanbanStatusLane from './WorkspaceKanbanStatusLane'
|
||||
|
||||
// Why: a fresh [] per render would defeat the memoized lane on empty lanes.
|
||||
const EMPTY_LANE_ITEMS: readonly Worktree[] = []
|
||||
const EMPTY_RENDERED_LANE_IDS: ReadonlySet<WorkspaceStatus> = new Set()
|
||||
const WORKSPACE_BOARD_LANE_GAP = 12
|
||||
const WORKSPACE_BOARD_LANE_OVERSCAN = 1
|
||||
|
||||
type WorkspaceKanbanLaneGridProps = {
|
||||
laneScrollerRef: React.RefObject<HTMLDivElement | null>
|
||||
statuses: readonly WorkspaceStatusDefinition[]
|
||||
laneViews: ReadonlyMap<WorkspaceStatus, WorkspaceKanbanLaneView>
|
||||
laneFullWorktreeIds: ReadonlyMap<WorkspaceStatus, readonly string[]>
|
||||
|
|
@ -41,6 +55,7 @@ type WorkspaceKanbanLaneGridProps = {
|
|||
}
|
||||
|
||||
export default function WorkspaceKanbanLaneGrid({
|
||||
laneScrollerRef,
|
||||
statuses,
|
||||
laneViews,
|
||||
laneFullWorktreeIds,
|
||||
|
|
@ -65,44 +80,155 @@ export default function WorkspaceKanbanLaneGrid({
|
|||
onColumnResizeStart,
|
||||
onColumnResizeKeyDown
|
||||
}: WorkspaceKanbanLaneGridProps): React.JSX.Element {
|
||||
const [focusedStatusId, setFocusedStatusId] = useState<WorkspaceStatus | null>(null)
|
||||
const [renderedLaneIds, setRenderedLaneIds] =
|
||||
useState<ReadonlySet<WorkspaceStatus>>(EMPTY_RENDERED_LANE_IDS)
|
||||
const renderedLaneIdsRef = useRef(renderedLaneIds)
|
||||
const renderCardsRef = useRef(renderCards)
|
||||
useLayoutEffect(() => {
|
||||
renderedLaneIdsRef.current = renderedLaneIds
|
||||
renderCardsRef.current = renderCards
|
||||
}, [renderCards, renderedLaneIds])
|
||||
const focusedIndex = useMemo(
|
||||
() =>
|
||||
focusedStatusId === null
|
||||
? null
|
||||
: statuses.findIndex((status) => status.id === focusedStatusId),
|
||||
[focusedStatusId, statuses]
|
||||
)
|
||||
const estimateLaneSize = useCallback(() => columnWidth, [columnWidth])
|
||||
const getLaneKey = useCallback((index: number) => statuses[index]?.id ?? index, [statuses])
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => extractWorkspaceKanbanLaneRange(range, focusedIndex),
|
||||
[focusedIndex]
|
||||
)
|
||||
const laneVirtualizer = useVirtualizer({
|
||||
count: statuses.length,
|
||||
getScrollElement: () => laneScrollerRef.current,
|
||||
estimateSize: estimateLaneSize,
|
||||
getItemKey: getLaneKey,
|
||||
horizontal: true,
|
||||
overscan: WORKSPACE_BOARD_LANE_OVERSCAN,
|
||||
gap: WORKSPACE_BOARD_LANE_GAP,
|
||||
rangeExtractor,
|
||||
useFlushSync: false
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
laneVirtualizer.measure()
|
||||
}, [columnWidth, laneVirtualizer])
|
||||
const virtualLanes = laneVirtualizer.getVirtualItems()
|
||||
const virtualStatusIds = useMemo(
|
||||
() =>
|
||||
virtualLanes.flatMap((virtualLane) => {
|
||||
const status = statuses[virtualLane.index]
|
||||
return status ? [status.id] : []
|
||||
}),
|
||||
[statuses, virtualLanes]
|
||||
)
|
||||
const mountedLaneIds = useMemo(() => new Set(virtualStatusIds), [virtualStatusIds])
|
||||
const mountedLaneIdsRef = useRef<ReadonlySet<WorkspaceStatus>>(mountedLaneIds)
|
||||
useLayoutEffect(() => {
|
||||
mountedLaneIdsRef.current = mountedLaneIds
|
||||
}, [mountedLaneIds])
|
||||
useEffect(() => {
|
||||
if (!renderCards) {
|
||||
setRenderedLaneIds(EMPTY_RENDERED_LANE_IDS)
|
||||
return
|
||||
}
|
||||
const missingIds = virtualStatusIds.filter((id) => !renderedLaneIdsRef.current.has(id))
|
||||
setRenderedLaneIds((current) => {
|
||||
const retained = new Set(Array.from(current).filter((id) => mountedLaneIds.has(id)))
|
||||
return retained.size === current.size ? current : retained
|
||||
})
|
||||
let nextIndex = 0
|
||||
let frameId = 0
|
||||
const renderNextLane = (): void => {
|
||||
const statusId = missingIds[nextIndex]
|
||||
nextIndex += 1
|
||||
if (!statusId) {
|
||||
return
|
||||
}
|
||||
startTransition(() => {
|
||||
setRenderedLaneIds((current) => {
|
||||
if (!renderCardsRef.current || !mountedLaneIdsRef.current.has(statusId)) {
|
||||
return current
|
||||
}
|
||||
return new Set(current).add(statusId)
|
||||
})
|
||||
})
|
||||
if (nextIndex < missingIds.length) {
|
||||
frameId = window.requestAnimationFrame(renderNextLane)
|
||||
}
|
||||
}
|
||||
if (missingIds.length > 0) {
|
||||
frameId = window.requestAnimationFrame(renderNextLane)
|
||||
}
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [mountedLaneIds, renderCards, virtualStatusIds])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid h-full min-h-0 min-w-full grid-rows-[minmax(0,1fr)] gap-3"
|
||||
className="relative h-full min-h-0 min-w-full"
|
||||
data-contextual-tour-target="workspace-board-lanes"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${statuses.length}, minmax(${columnWidth}px, ${columnWidth}px))`
|
||||
data-workspace-board-lane-grid=""
|
||||
style={{ width: `${laneVirtualizer.getTotalSize()}px` }}
|
||||
onFocusCapture={(event) => {
|
||||
const lane = (event.target as Element).closest<HTMLElement>('[data-workspace-status]')
|
||||
setFocusedStatusId(lane?.dataset.workspaceStatus ?? null)
|
||||
}}
|
||||
onBlurCapture={(event) => {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
|
||||
setFocusedStatusId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{statuses.map((status) => (
|
||||
<WorkspaceKanbanStatusLane
|
||||
key={status.id}
|
||||
status={status}
|
||||
items={laneViews.get(status.id)?.items ?? EMPTY_LANE_ITEMS}
|
||||
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
|
||||
hasQuery={hasQuery}
|
||||
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
isResizingColumn={isResizingColumn}
|
||||
isDragTarget={dragOverStatus === status.id}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
renderCards={renderCards}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={false}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
onCreateWorktree={onCreateWorktree}
|
||||
onColumnResizeStart={onColumnResizeStart}
|
||||
onColumnResizeKeyDown={onColumnResizeKeyDown}
|
||||
/>
|
||||
))}
|
||||
{virtualLanes.map((virtualLane) => {
|
||||
const status = statuses[virtualLane.index]
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualLane.key}
|
||||
ref={laneVirtualizer.measureElement}
|
||||
data-index={virtualLane.index}
|
||||
className="absolute left-0 top-0 h-full"
|
||||
style={{
|
||||
width: `${columnWidth}px`,
|
||||
transform: `translateX(${virtualLane.start}px)`
|
||||
}}
|
||||
>
|
||||
<WorkspaceKanbanStatusLane
|
||||
status={status}
|
||||
items={laneViews.get(status.id)?.items ?? EMPTY_LANE_ITEMS}
|
||||
totalCount={laneViews.get(status.id)?.totalCount ?? 0}
|
||||
hasQuery={hasQuery}
|
||||
fullWorktreeIds={laneFullWorktreeIds.get(status.id) ?? []}
|
||||
repoMap={repoMap}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
columnWidth={columnWidth}
|
||||
isResizingColumn={isResizingColumn}
|
||||
isDragTarget={dragOverStatus === status.id}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
renderCards={renderCards && renderedLaneIds.has(status.id)}
|
||||
selectedWorktreeIds={selectedWorktreeIds}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
nativeDragEnabled={false}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onActivate={onActivate}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={onContextMenuSelect}
|
||||
onAssignWorkspaceStatus={onAssignWorkspaceStatus}
|
||||
onCreateWorktree={onCreateWorktree}
|
||||
onColumnResizeStart={onColumnResizeStart}
|
||||
onColumnResizeKeyDown={onColumnResizeKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,14 +25,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 +48,7 @@ function renderMenu(onSyncTaskStatusFromWorkspaceBoardChange = vi.fn()): void {
|
|||
onChangeStatusIcon={vi.fn()}
|
||||
onMoveStatus={vi.fn()}
|
||||
onRemoveStatus={vi.fn()}
|
||||
onAddStatus={vi.fn()}
|
||||
onAddStatus={onAddStatus}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
@ -59,7 +67,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 +82,23 @@ describe('WorkspaceKanbanSettingsMenu', () => {
|
|||
|
||||
expect(onChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('keeps adding available for workflows above the former board limit', () => {
|
||||
const onAddStatus = vi.fn()
|
||||
renderMenu({
|
||||
workspaceStatuses: Array.from({ length: 21 }, (_, index) => ({
|
||||
id: `state-${index + 1}`,
|
||||
label: `State ${index + 1}`
|
||||
})),
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
import { defaultRangeExtractor, type Range } from '@tanstack/react-virtual'
|
||||
|
||||
export function extractWorkspaceKanbanLaneRange(
|
||||
range: Range,
|
||||
focusedIndex: number | null
|
||||
): number[] {
|
||||
const indexes = defaultRangeExtractor(range)
|
||||
if (
|
||||
focusedIndex === null ||
|
||||
focusedIndex < 0 ||
|
||||
focusedIndex >= range.count ||
|
||||
indexes.includes(focusedIndex)
|
||||
) {
|
||||
return indexes
|
||||
}
|
||||
return [...indexes, focusedIndex].sort((left, right) => left - right)
|
||||
}
|
||||
|
|
@ -10,6 +10,29 @@ import {
|
|||
} from './workspace-statuses'
|
||||
|
||||
describe('workspace status visuals', () => {
|
||||
it.each([13, 20, 21, 64])('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(count)
|
||||
expect(statuses.map((status) => status.id)).toEqual(authored.map((status) => status.id))
|
||||
})
|
||||
|
||||
it('normalizes every valid status without truncating the workflow', () => {
|
||||
const authored = Array.from({ length: 500 }, (_, index) => ({
|
||||
id: `state-${index}`,
|
||||
label: `State ${index}`
|
||||
}))
|
||||
|
||||
expect(normalizeWorkspaceStatuses(authored).map((status) => status.id)).toEqual(
|
||||
authored.map((status) => status.id)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the default workflow order', () => {
|
||||
expect(cloneDefaultWorkspaceStatuses().map((status) => status.id)).toEqual([
|
||||
'todo',
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export { DEFAULT_WORKSPACE_STATUSES } from './workspace-status-defaults'
|
|||
|
||||
const WORKSPACE_STATUS_GROUP_PREFIX = 'workspace-status:'
|
||||
const MAX_STATUS_LABEL_LENGTH = 32
|
||||
const MAX_WORKSPACE_STATUSES = 12
|
||||
type WorkspaceStatusNormalizationOptions = {
|
||||
migrateDefaultWorkflowStatuses?: boolean
|
||||
migrateLegacyDefaultStatusVisuals?: boolean
|
||||
|
|
@ -169,7 +168,7 @@ function normalizeWorkspaceStatusesInternal(
|
|||
|
||||
const statuses: WorkspaceStatusDefinition[] = []
|
||||
const usedIds = new Set<string>()
|
||||
for (const rawStatus of value.slice(0, MAX_WORKSPACE_STATUSES)) {
|
||||
for (const rawStatus of value) {
|
||||
if (!rawStatus || typeof rawStatus !== 'object' || Array.isArray(rawStatus)) {
|
||||
continue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
|||
|
||||
const SEEDED_WORKSPACE_COUNT = 300
|
||||
const MARQUEE_WORKSPACE_COUNT = 102
|
||||
const MANY_LANE_COUNT = 21
|
||||
const CARDS_PER_LANE = 100
|
||||
|
||||
/**
|
||||
* Why: the board used to mount every workspace card in every lane in one
|
||||
|
|
@ -151,6 +153,159 @@ test.describe('Workspace board lane virtualization', () => {
|
|||
await expect.poll(readMaxIndex, { timeout: 15_000 }).toBeGreaterThan(before)
|
||||
})
|
||||
|
||||
test('bounds mounted lanes and cards while preserving a 21-status workflow', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const statusIds = Array.from(
|
||||
{ length: MANY_LANE_COUNT },
|
||||
(_, index) => `state-${String(index + 1).padStart(2, '0')}`
|
||||
)
|
||||
await orcaPage.evaluate(
|
||||
({ cardsPerLane, ids }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const repo = state.repos[0]
|
||||
if (!repo) {
|
||||
throw new Error('Expected a seeded e2e repo')
|
||||
}
|
||||
const now = Date.now()
|
||||
const synthetic = ids.flatMap((status, statusIndex) =>
|
||||
Array.from({ length: cardsPerLane }, (_, cardIndex) => {
|
||||
const suffix = `${String(statusIndex + 1).padStart(2, '0')}-${String(
|
||||
cardIndex + 1
|
||||
).padStart(3, '0')}`
|
||||
return {
|
||||
id: `${repo.id}::/virtual-lane-${suffix}`,
|
||||
instanceId: `virtual-lane-${suffix}`,
|
||||
repoId: repo.id,
|
||||
path: `${repo.path}/../virtual-lane-${suffix}`,
|
||||
displayName: `Virtual lane ${suffix}`,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 20_000 - statusIndex * cardsPerLane - cardIndex,
|
||||
manualOrder: 20_000 - statusIndex * cardsPerLane - cardIndex,
|
||||
lastActivityAt: now - statusIndex * cardsPerLane - cardIndex,
|
||||
head: '0000000000000000000000000000000000000000',
|
||||
branch: `virtual-lane-${suffix}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
workspaceStatus: status
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
state.setSidebarOpen(true)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
state.setWorkspaceBoardColumnWidth(308)
|
||||
state.setWorkspaceStatuses(
|
||||
ids.map((id, index) => ({
|
||||
id,
|
||||
label: `State ${index + 1}`
|
||||
}))
|
||||
)
|
||||
store.setState({
|
||||
sortBy: 'manual',
|
||||
worktreesByRepo: { ...state.worktreesByRepo, [repo.id]: synthetic }
|
||||
})
|
||||
},
|
||||
{ cardsPerLane: CARDS_PER_LANE, ids: statusIds }
|
||||
)
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
|
||||
|
||||
const board = orcaPage.locator('[data-workspace-board-selection-surface]')
|
||||
const scroller = board.locator('[data-workspace-board-lane-grid]').locator('..')
|
||||
const lanes = board.locator('[data-workspace-status]')
|
||||
const cards = board.locator('[data-workspace-board-card-id]')
|
||||
await expect.poll(() => cards.count(), { timeout: 15_000 }).toBeGreaterThan(3)
|
||||
|
||||
const laneBudget = await scroller.evaluate(
|
||||
(element) => Math.ceil(element.clientWidth / 320) + 3
|
||||
)
|
||||
const initialLaneCount = await lanes.count()
|
||||
expect(initialLaneCount).toBeLessThanOrEqual(laneBudget)
|
||||
expect(await cards.count()).toBeLessThan(initialLaneCount * 40)
|
||||
expect(await board.locator('*').count()).toBeLessThan(initialLaneCount * 550 + 200)
|
||||
await expect(board.locator('[data-workspace-status="state-01"]')).toBeVisible()
|
||||
expect(await board.locator('[data-workspace-status="state-21"]').count()).toBe(0)
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollLeft = element.scrollWidth
|
||||
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
})
|
||||
|
||||
await expect(board.locator('[data-workspace-status="state-21"]')).toBeVisible()
|
||||
await expect.poll(() => board.locator('[data-workspace-status="state-01"]').count()).toBe(0)
|
||||
const finalIds = await lanes.evaluateAll((elements) =>
|
||||
elements.map((element) => (element as HTMLElement).dataset.workspaceStatus ?? '')
|
||||
)
|
||||
expect(finalIds).toEqual([...finalIds].sort())
|
||||
expect(finalIds).toContain('state-21')
|
||||
expect(await lanes.count()).toBeLessThanOrEqual(laneBudget)
|
||||
expect(await cards.count()).toBeLessThan((await lanes.count()) * 40)
|
||||
expect(
|
||||
await orcaPage.evaluate(() =>
|
||||
window.__store?.getState().workspaceStatuses.map((status) => status.id)
|
||||
)
|
||||
).toEqual(statusIds)
|
||||
|
||||
const finalLane = board.locator('[data-workspace-status="state-21"]')
|
||||
const resizeHandle = finalLane.getByRole('separator', {
|
||||
name: 'Resize workspace board columns'
|
||||
})
|
||||
await resizeHandle.focus()
|
||||
await resizeHandle.press('ArrowRight')
|
||||
await expect
|
||||
.poll(() => orcaPage.evaluate(() => window.__store?.getState().workspaceBoardColumnWidth))
|
||||
.toBe(328)
|
||||
await expect(resizeHandle).toHaveAttribute('aria-valuenow', '328')
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollLeft = element.scrollWidth
|
||||
element.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
})
|
||||
await expect(finalLane).toBeVisible()
|
||||
|
||||
const sourceCard = board
|
||||
.locator('[data-workspace-status="state-20"] [data-workspace-board-card-id]')
|
||||
.first()
|
||||
const sourceId = await sourceCard.getAttribute('data-workspace-board-card-id')
|
||||
const sourceBox = await sourceCard.boundingBox()
|
||||
const targetBox = await finalLane
|
||||
.locator('[data-workspace-board-lane-scroll]')
|
||||
.first()
|
||||
.boundingBox()
|
||||
if (!sourceId || !sourceBox || !targetBox) {
|
||||
throw new Error('Expected visible source card and final lane drop target')
|
||||
}
|
||||
await orcaPage.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
|
||||
await orcaPage.mouse.down()
|
||||
await orcaPage.mouse.move(
|
||||
targetBox.x + targetBox.width / 2,
|
||||
targetBox.y + Math.min(80, targetBox.height / 2),
|
||||
{ steps: 8 }
|
||||
)
|
||||
await orcaPage.mouse.up()
|
||||
await expect
|
||||
.poll(() =>
|
||||
orcaPage.evaluate(
|
||||
(worktreeId) =>
|
||||
window.__store?.getState().getKnownWorktreeById(worktreeId)?.workspaceStatus,
|
||||
sourceId
|
||||
)
|
||||
)
|
||||
.toBe('state-21')
|
||||
})
|
||||
|
||||
test('selects the full lane across a single large marquee scroll jump', async ({ orcaPage }) => {
|
||||
const statusId = 'virtual-marquee'
|
||||
await orcaPage.evaluate(
|
||||
|
|
|
|||
Loading…
Reference in New Issue