mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
feat: add search to workspace kanban board
Adds a search field to filter the kanban board by workspace name. Range selections now index rendered cards only, preventing silent selection of hidden items when filtering. Selection badges count only the visible cards that drag/context-menu actions will move. Lane totals distinguish between empty-by-definition and filtered-away cards. Drop operations commit against the full lane while displaying filtered indices. Whitespace-only queries don't show match counts, since they don't narrow the board.
This commit is contained in:
@@ -9,7 +9,9 @@ import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
|
||||
import type { WorkspaceKanbanLaneView } from './workspace-kanban-search'
|
||||
|
||||
type HeaderCapture = {
|
||||
selectedCount: number
|
||||
query: string
|
||||
isFiltering: boolean
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
@@ -42,7 +44,8 @@ const {
|
||||
headerState,
|
||||
gridState,
|
||||
pointerDragState,
|
||||
selectionState
|
||||
selectionState,
|
||||
selectionScopeState
|
||||
} = vi.hoisted(() => ({
|
||||
syncWorkspaceBoardTaskStatusesMock: vi.fn(() =>
|
||||
Promise.resolve({ updated: 1, skipped: 0, failed: 0, messages: [] })
|
||||
@@ -50,7 +53,8 @@ const {
|
||||
headerState: { current: null as HeaderCapture | null },
|
||||
gridState: { current: null as GridCapture | null },
|
||||
pointerDragState: { current: null as PointerDragCapture | null },
|
||||
selectionState: { current: [] as Worktree[] }
|
||||
selectionState: { current: [] as Worktree[] },
|
||||
selectionScopeState: { current: [] as readonly Worktree[] }
|
||||
}))
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
@@ -88,15 +92,22 @@ vi.mock('./use-visible-workspace-kanban-worktree-ids', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-selection', () => ({
|
||||
useWorkspaceKanbanSelection: () => ({
|
||||
selectedWorktreeIds: new Set(selectionState.current.map((worktree) => worktree.id)),
|
||||
selectedWorktrees: selectionState.current,
|
||||
selectionAnchorId: null,
|
||||
updateSelectionForGesture: vi.fn(),
|
||||
updateSelectionForArea: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
selectForContextMenu: vi.fn(() => selectionState.current)
|
||||
})
|
||||
useWorkspaceKanbanSelection: (
|
||||
_open: boolean,
|
||||
boardWorktrees: readonly Worktree[],
|
||||
renderedWorktrees?: readonly Worktree[]
|
||||
) => {
|
||||
selectionScopeState.current = renderedWorktrees ?? boardWorktrees
|
||||
return {
|
||||
selectedWorktreeIds: new Set(selectionState.current.map((worktree) => worktree.id)),
|
||||
selectedWorktrees: selectionState.current,
|
||||
selectionAnchorId: null,
|
||||
updateSelectionForGesture: vi.fn(),
|
||||
updateSelectionForArea: vi.fn(),
|
||||
clearSelection: vi.fn(),
|
||||
selectForContextMenu: vi.fn(() => selectionState.current)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./use-workspace-kanban-area-selection', () => ({
|
||||
@@ -217,6 +228,7 @@ beforeEach(() => {
|
||||
gridState.current = null
|
||||
pointerDragState.current = null
|
||||
selectionState.current = []
|
||||
selectionScopeState.current = []
|
||||
syncWorkspaceBoardTaskStatusesMock.mockClear()
|
||||
updateWorktreesMeta = vi.fn<UpdateWorktreesMeta>(() => Promise.resolve())
|
||||
useAppStore.setState({
|
||||
@@ -334,6 +346,34 @@ describe('WorkspaceKanbanDrawer search', () => {
|
||||
expect(gridState.current?.selectedWorktrees).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('counts only the rendered cards in the header selection badge', () => {
|
||||
selectionState.current = [alpha, gamma]
|
||||
renderDrawer()
|
||||
expect(headerState.current?.selectedCount).toBe(2)
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(headerState.current?.selectedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('scopes selection gestures to the rendered cards', () => {
|
||||
renderDrawer()
|
||||
expect(selectionScopeState.current).toHaveLength(5)
|
||||
|
||||
typeQuery('gamma')
|
||||
|
||||
expect(selectionScopeState.current).toEqual([gamma])
|
||||
})
|
||||
|
||||
it('reports a non-filtering query so the header withholds match counts', () => {
|
||||
renderDrawer()
|
||||
|
||||
typeQuery(' ')
|
||||
|
||||
expect(headerState.current).toMatchObject({ isFiltering: false, matchCount: 5, totalCount: 5 })
|
||||
expect(laneIds('todo')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('ranks a drop into a filtered lane against the full lane, not the rendered one', () => {
|
||||
renderDrawer()
|
||||
typeQuery('gamma')
|
||||
|
||||
@@ -204,6 +204,15 @@ export default function WorkspaceKanbanDrawer({
|
||||
() => buildWorkspaceKanbanLaneViews({ worktreesByStatus, matchingWorktreeIds }),
|
||||
[matchingWorktreeIds, worktreesByStatus]
|
||||
)
|
||||
// Why: range and area gestures must index the cards the user can actually see,
|
||||
// or a shift-click across a filtered gap silently selects hidden workspaces.
|
||||
const renderedBoardWorktrees = useMemo(
|
||||
() =>
|
||||
matchingWorktreeIds
|
||||
? boardWorktrees.filter((worktree) => matchingWorktreeIds.has(worktree.id))
|
||||
: boardWorktrees,
|
||||
[boardWorktrees, matchingWorktreeIds]
|
||||
)
|
||||
const {
|
||||
selectedWorktreeIds,
|
||||
selectedWorktrees,
|
||||
@@ -212,7 +221,7 @@ export default function WorkspaceKanbanDrawer({
|
||||
updateSelectionForArea,
|
||||
clearSelection,
|
||||
selectForContextMenu
|
||||
} = useWorkspaceKanbanSelection(open, boardWorktrees)
|
||||
} = useWorkspaceKanbanSelection(open, boardWorktrees, renderedBoardWorktrees)
|
||||
const { handleAreaSelectionPointerDown } = useWorkspaceKanbanAreaSelection({
|
||||
open,
|
||||
boardRef,
|
||||
@@ -800,8 +809,11 @@ export default function WorkspaceKanbanDrawer({
|
||||
}}
|
||||
>
|
||||
<WorkspaceKanbanDrawerHeader
|
||||
selectedCount={selectedWorktrees.length}
|
||||
// Why: the badge has to count what a drag or context-menu action will
|
||||
// actually move, which under a query is the rendered subset.
|
||||
selectedCount={renderedSelectedWorktrees.length}
|
||||
query={query}
|
||||
isFiltering={hasQuery}
|
||||
matchCount={matchingWorktreeIds?.size ?? boardWorktrees.length}
|
||||
totalCount={boardWorktrees.length}
|
||||
onQueryChange={setQuery}
|
||||
|
||||
@@ -57,6 +57,7 @@ function renderHeader(
|
||||
return WorkspaceKanbanDrawerHeader({
|
||||
selectedCount: 0,
|
||||
query: '',
|
||||
isFiltering: false,
|
||||
matchCount: 0,
|
||||
totalCount: 0,
|
||||
onQueryChange: vi.fn(),
|
||||
@@ -92,18 +93,33 @@ describe('WorkspaceKanbanDrawerHeader', () => {
|
||||
})
|
||||
|
||||
it('renders the search field as a sibling of the sheet title, not inside it', () => {
|
||||
const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
const header = renderHeader(vi.fn(), {
|
||||
query: 'orca',
|
||||
isFiltering: true,
|
||||
matchCount: 2,
|
||||
totalCount: 15
|
||||
})
|
||||
|
||||
const title = findByType(header, SheetTitle)
|
||||
expect(title).not.toBeNull()
|
||||
expect(findByType(title, WorkspaceKanbanSearchField)).toBeNull()
|
||||
|
||||
const field = findByType(header, WorkspaceKanbanSearchField)
|
||||
expect(field?.props).toMatchObject({ query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
expect(field?.props).toMatchObject({
|
||||
query: 'orca',
|
||||
isFiltering: true,
|
||||
matchCount: 2,
|
||||
totalCount: 15
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the filter, settings, and close cluster reachable alongside the field', () => {
|
||||
const header = renderHeader(vi.fn(), { query: 'orca', matchCount: 2, totalCount: 15 })
|
||||
const header = renderHeader(vi.fn(), {
|
||||
query: 'orca',
|
||||
isFiltering: true,
|
||||
matchCount: 2,
|
||||
totalCount: 15
|
||||
})
|
||||
|
||||
expect(findByType(header, SidebarFilter)).not.toBeNull()
|
||||
expect(findByType(header, WorkspaceKanbanSettingsMenu)).not.toBeNull()
|
||||
|
||||
@@ -11,6 +11,7 @@ import { translate } from '@/i18n/i18n'
|
||||
type WorkspaceKanbanDrawerHeaderProps = {
|
||||
selectedCount: number
|
||||
query: string
|
||||
isFiltering: boolean
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
@@ -31,6 +32,7 @@ type WorkspaceKanbanDrawerHeaderProps = {
|
||||
export default function WorkspaceKanbanDrawerHeader({
|
||||
selectedCount,
|
||||
query,
|
||||
isFiltering,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
@@ -72,6 +74,7 @@ export default function WorkspaceKanbanDrawerHeader({
|
||||
</SheetTitle>
|
||||
<WorkspaceKanbanSearchField
|
||||
query={query}
|
||||
isFiltering={isFiltering}
|
||||
matchCount={matchCount}
|
||||
totalCount={totalCount}
|
||||
onQueryChange={onQueryChange}
|
||||
|
||||
@@ -12,11 +12,17 @@ let root: Root
|
||||
const onQueryChange = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
|
||||
function renderField(props: { query: string; matchCount?: number; totalCount?: number }): void {
|
||||
function renderField(props: {
|
||||
query: string
|
||||
isFiltering?: boolean
|
||||
matchCount?: number
|
||||
totalCount?: number
|
||||
}): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceKanbanSearchField
|
||||
query={props.query}
|
||||
isFiltering={props.isFiltering ?? props.query.trim() !== ''}
|
||||
matchCount={props.matchCount ?? 0}
|
||||
totalCount={props.totalCount ?? 0}
|
||||
onQueryChange={onQueryChange}
|
||||
@@ -97,6 +103,18 @@ describe('WorkspaceKanbanSearchField', () => {
|
||||
expect(clearButton()?.getAttribute('aria-label')).toBe('Clear search')
|
||||
})
|
||||
|
||||
it('withholds counts for text that never narrows the board', () => {
|
||||
renderField({ query: ' ', isFiltering: false, matchCount: 12, totalCount: 12 })
|
||||
|
||||
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
expect(clearButton()).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
expect(liveRegion().textContent).toBe('')
|
||||
})
|
||||
|
||||
it('announces match counts only after the query settles', () => {
|
||||
renderField({ query: 'orca', matchCount: 3, totalCount: 12 })
|
||||
expect(liveRegion().textContent).toBe('')
|
||||
|
||||
@@ -9,6 +9,8 @@ const ANNOUNCE_DEBOUNCE_MS = 400
|
||||
|
||||
type WorkspaceKanbanSearchFieldProps = {
|
||||
query: string
|
||||
/** False for text that never narrows the board (whitespace-only, over-bound). */
|
||||
isFiltering: boolean
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
onQueryChange: (query: string) => void
|
||||
@@ -30,18 +32,19 @@ function formatAnnouncement(matchCount: number, totalCount: number): string {
|
||||
|
||||
export default function WorkspaceKanbanSearchField({
|
||||
query,
|
||||
isFiltering,
|
||||
matchCount,
|
||||
totalCount,
|
||||
onQueryChange,
|
||||
onClear
|
||||
}: WorkspaceKanbanSearchFieldProps): React.JSX.Element {
|
||||
const hasQuery = query !== ''
|
||||
const hasText = query !== ''
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// Why: the filter itself is undebounced, but a polite live region that changes
|
||||
// on every keystroke produces continuous speech and makes the field unusable.
|
||||
useEffect(() => {
|
||||
if (!hasQuery) {
|
||||
if (!isFiltering) {
|
||||
setAnnouncement('')
|
||||
return
|
||||
}
|
||||
@@ -50,7 +53,7 @@ export default function WorkspaceKanbanSearchField({
|
||||
ANNOUNCE_DEBOUNCE_MS
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [hasQuery, matchCount, totalCount])
|
||||
}, [isFiltering, matchCount, totalCount])
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 items-center max-w-[268px]">
|
||||
@@ -67,13 +70,13 @@ export default function WorkspaceKanbanSearchField({
|
||||
)}
|
||||
className={cn(
|
||||
'h-7 border-worktree-sidebar-border bg-background pl-7 text-xs',
|
||||
hasQuery && 'pr-16'
|
||||
hasText && (isFiltering ? 'pr-16' : 'pr-8')
|
||||
)}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// Why: the board swallows Radix close requests, so an empty-field
|
||||
// Escape has nothing local to do and is left to bubble.
|
||||
if (event.key !== 'Escape' || !hasQuery) {
|
||||
if (event.key !== 'Escape' || !hasText) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -81,11 +84,13 @@ export default function WorkspaceKanbanSearchField({
|
||||
onClear()
|
||||
}}
|
||||
/>
|
||||
{hasQuery ? (
|
||||
{hasText ? (
|
||||
<div className="absolute right-1 flex items-center gap-0.5">
|
||||
<span aria-hidden="true" className="text-[10px] text-muted-foreground">
|
||||
{matchCount} / {totalCount}
|
||||
</span>
|
||||
{isFiltering ? (
|
||||
<span aria-hidden="true" className="text-[10px] text-muted-foreground">
|
||||
{matchCount} / {totalCount}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('WorkspaceKanbanStatusLane', () => {
|
||||
expect(container.textContent).not.toContain('No matches')
|
||||
})
|
||||
|
||||
it('leaves an already-empty lane as Empty under a query rather than "No matches"', () => {
|
||||
renderLane({ items: [], totalCount: 0, hasQuery: true, fullWorktreeIds: [] })
|
||||
|
||||
expect(container.textContent).toContain('Empty')
|
||||
expect(container.textContent).not.toContain('No matches')
|
||||
expect(container.textContent).not.toContain('0 / 0')
|
||||
})
|
||||
|
||||
it('publishes the full lane membership even when the rendered set is a subset', () => {
|
||||
renderLane({
|
||||
items: [worktree('b')],
|
||||
@@ -122,4 +130,17 @@ describe('WorkspaceKanbanStatusLane', () => {
|
||||
expect(lane().dataset.workspaceLaneFullIds).toBe('a\nb\nc')
|
||||
expect(container.querySelectorAll('[data-workspace-board-card-id]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stays off the full-id channel when nothing is filtered', () => {
|
||||
// Why: without a query the rendered card scan already is the full lane, and
|
||||
// the attribute would carry every board id for no reader.
|
||||
renderLane({
|
||||
items: [worktree('a'), worktree('b')],
|
||||
totalCount: 2,
|
||||
hasQuery: false,
|
||||
fullWorktreeIds: ['a', 'b']
|
||||
})
|
||||
|
||||
expect(lane().dataset.workspaceLaneFullIds).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { translate } from '@/i18n/i18n'
|
||||
type WorkspaceKanbanStatusLaneProps = {
|
||||
status: WorkspaceStatusDefinition
|
||||
items: readonly Worktree[]
|
||||
/** Lane membership before search filtering; defaults to the rendered items. */
|
||||
totalCount?: number
|
||||
hasQuery?: boolean
|
||||
fullWorktreeIds?: readonly string[]
|
||||
@@ -75,6 +76,10 @@ export default function WorkspaceKanbanStatusLane({
|
||||
onColumnResizeKeyDown
|
||||
}: WorkspaceKanbanStatusLaneProps): React.JSX.Element {
|
||||
const meta = getWorkspaceStatusVisualMeta(status)
|
||||
// Why: a lane that is empty on its own merits is still "Empty" under a query —
|
||||
// only a lane whose cards were filtered away has anything to say about matches.
|
||||
const laneTotalCount = totalCount ?? items.length
|
||||
const isFiltered = hasQuery && laneTotalCount > 0
|
||||
const createTooltip = canCreateWorktree
|
||||
? `New workspace in ${status.label}`
|
||||
: 'Add a project to create workspaces'
|
||||
@@ -98,9 +103,13 @@ export default function WorkspaceKanbanStatusLane({
|
||||
data-workspace-status={status.id}
|
||||
// Why: sidebar→board drops read lane membership straight out of the DOM,
|
||||
// where a search query would otherwise leave them only the rendered cards.
|
||||
data-workspace-lane-full-ids={serializeWorkspaceLaneFullIds(
|
||||
fullWorktreeIds ?? items.map((worktree) => worktree.id)
|
||||
)}
|
||||
// Unfiltered lanes stay off this channel — the rendered scan already is the
|
||||
// full lane, and every board id in an attribute is real DOM weight.
|
||||
data-workspace-lane-full-ids={
|
||||
hasQuery
|
||||
? serializeWorkspaceLaneFullIds(fullWorktreeIds ?? items.map((worktree) => worktree.id))
|
||||
: undefined
|
||||
}
|
||||
data-contextual-tour-target={
|
||||
status.id === 'completed' ? 'workspace-board-done-lane' : undefined
|
||||
}
|
||||
@@ -152,7 +161,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
{status.label}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium leading-none text-muted-foreground">
|
||||
{hasQuery ? `${items.length} / ${totalCount ?? items.length}` : items.length}
|
||||
{isFiltered ? `${items.length} / ${laneTotalCount}` : items.length}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
@@ -192,7 +201,7 @@ export default function WorkspaceKanbanStatusLane({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded-md border border-dashed border-border/70 text-[11px] text-muted-foreground">
|
||||
{hasQuery
|
||||
{isFiltered
|
||||
? translate(
|
||||
'auto.components.sidebar.WorkspaceKanbanStatusLane.2df01a03ff',
|
||||
'No matches'
|
||||
|
||||
@@ -170,6 +170,7 @@ import {
|
||||
getWorkspaceKanbanSidebarDropTarget,
|
||||
hasWorkspaceKanbanSidebarDropBoard,
|
||||
isWorkspaceKanbanSidebarDropPointInBoard,
|
||||
resolveWorkspaceKanbanSidebarFullLaneDropIndex,
|
||||
updateWorkspaceKanbanSidebarDropTargetVisual
|
||||
} from './workspace-kanban-sidebar-drop'
|
||||
import {
|
||||
@@ -3220,7 +3221,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onDropWorktreesOnWorkspaceBoard({
|
||||
worktreeIds: drag.reorderDraggedIds,
|
||||
status: boardDropTarget.status,
|
||||
dropIndex: boardDropTarget.dropIndex,
|
||||
// Why: the target counts rendered cards, but the groups are the full
|
||||
// lane. Board search can make those two differ.
|
||||
dropIndex: resolveWorkspaceKanbanSidebarFullLaneDropIndex(
|
||||
boardDropTarget.status,
|
||||
boardDropTarget.dropIndex
|
||||
),
|
||||
groups: getWorkspaceKanbanSidebarDropGroups()
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { useWorkspaceKanbanSelection } from './use-workspace-kanban-selection'
|
||||
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
type Selection = ReturnType<typeof useWorkspaceKanbanSelection>
|
||||
|
||||
function worktree(id: string): Worktree {
|
||||
return { id, repoId: 'repo-a', displayName: id } as Worktree
|
||||
}
|
||||
|
||||
const alpha = worktree('alpha')
|
||||
const beta = worktree('beta')
|
||||
const gamma = worktree('gamma')
|
||||
const delta = worktree('delta')
|
||||
const fullBoard = [alpha, beta, gamma, delta]
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
let selection: Selection
|
||||
|
||||
function Probe({
|
||||
board,
|
||||
rendered
|
||||
}: {
|
||||
board: readonly Worktree[]
|
||||
rendered: readonly Worktree[]
|
||||
}): null {
|
||||
selection = useWorkspaceKanbanSelection(true, board, rendered)
|
||||
return null
|
||||
}
|
||||
|
||||
function renderSelection(
|
||||
rendered: readonly Worktree[] = fullBoard,
|
||||
board: readonly Worktree[] = fullBoard
|
||||
): void {
|
||||
act(() => {
|
||||
root.render(<Probe board={board} rendered={rendered} />)
|
||||
})
|
||||
}
|
||||
|
||||
function click(worktreeId: string, shiftKey = false): void {
|
||||
act(() => {
|
||||
selection.updateSelectionForGesture(
|
||||
{ metaKey: false, ctrlKey: false, shiftKey } as React.MouseEvent<HTMLElement>,
|
||||
worktreeId
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function selectedIds(): string[] {
|
||||
return [...selection.selectedWorktreeIds].sort()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
})
|
||||
|
||||
describe('useWorkspaceKanbanSelection', () => {
|
||||
it('ranges across the whole board when nothing is filtered', () => {
|
||||
renderSelection()
|
||||
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta', 'gamma'])
|
||||
})
|
||||
|
||||
it('never ranges through cards a search has hidden', () => {
|
||||
renderSelection([alpha, gamma])
|
||||
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'gamma'])
|
||||
})
|
||||
|
||||
it('keeps a hidden card selected so clearing the search restores the selection', () => {
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
click(beta.id, true)
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
|
||||
renderSelection([alpha])
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
|
||||
renderSelection()
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('still prunes ids that leave the board entirely', () => {
|
||||
renderSelection()
|
||||
click(alpha.id)
|
||||
click(gamma.id, true)
|
||||
|
||||
renderSelection([alpha, beta], [alpha, beta])
|
||||
|
||||
expect(selectedIds()).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,22 @@ import {
|
||||
updateWorktreeSelection
|
||||
} from './worktree-multi-selection'
|
||||
|
||||
export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: readonly Worktree[]) {
|
||||
// Why: board search hides cards without dropping them from the board, so range
|
||||
// and area gestures index the rendered subset while pruning still spans the
|
||||
// whole board — otherwise a query would silently discard hidden selections.
|
||||
export function useWorkspaceKanbanSelection(
|
||||
open: boolean,
|
||||
boardWorktrees: readonly Worktree[],
|
||||
renderedWorktrees: readonly Worktree[] = boardWorktrees
|
||||
) {
|
||||
const boardWorktreeIds = useMemo(
|
||||
() => boardWorktrees.map((worktree) => worktree.id),
|
||||
[boardWorktrees]
|
||||
)
|
||||
const renderedWorktreeIds = useMemo(
|
||||
() => renderedWorktrees.map((worktree) => worktree.id),
|
||||
[renderedWorktrees]
|
||||
)
|
||||
const [selectedWorktreeIds, setSelectedWorktreeIds] = useState<Set<string>>(new Set())
|
||||
const [selectionAnchorId, setSelectionAnchorId] = useState<string | null>(null)
|
||||
const selectedWorktrees = useMemo(
|
||||
@@ -43,7 +54,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
||||
(event: React.MouseEvent<HTMLElement>, worktreeId: string): boolean => {
|
||||
const intent = getWorktreeSelectionIntent(event, navigator.userAgent.includes('Mac'))
|
||||
const result = updateWorktreeSelection({
|
||||
visibleIds: boardWorktreeIds,
|
||||
visibleIds: renderedWorktreeIds,
|
||||
previousSelectedIds: selectedWorktreeIds,
|
||||
previousAnchorId: selectionAnchorId,
|
||||
targetId: worktreeId,
|
||||
@@ -53,7 +64,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
||||
setSelectionAnchorId(result.anchorId)
|
||||
return intent !== 'replace'
|
||||
},
|
||||
[boardWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
[renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
)
|
||||
|
||||
const selectForContextMenu = useCallback(
|
||||
@@ -76,7 +87,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
||||
baseAnchorId: string | null = selectionAnchorId
|
||||
): void => {
|
||||
const result = updateWorktreeAreaSelection({
|
||||
visibleIds: boardWorktreeIds,
|
||||
visibleIds: renderedWorktreeIds,
|
||||
previousSelectedIds: baseSelectedIds,
|
||||
previousAnchorId: baseAnchorId,
|
||||
areaIds,
|
||||
@@ -89,7 +100,7 @@ export function useWorkspaceKanbanSelection(open: boolean, boardWorktrees: reado
|
||||
previous === result.anchorId ? previous : result.anchorId
|
||||
)
|
||||
},
|
||||
[boardWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
[renderedWorktreeIds, selectedWorktreeIds, selectionAnchorId]
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getWorkspaceKanbanSidebarDropGroups,
|
||||
getWorkspaceKanbanSidebarDropTarget,
|
||||
isWorkspaceKanbanSidebarDropPointInBoard,
|
||||
resolveWorkspaceKanbanSidebarFullLaneDropIndex,
|
||||
updateWorkspaceKanbanSidebarDropTargetVisual
|
||||
} from './workspace-kanban-sidebar-drop'
|
||||
|
||||
@@ -257,16 +258,33 @@ describe('workspace kanban sidebar drop DOM bridge', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('translates the rendered drop index onto the full lane', () => {
|
||||
it('keeps the tracked drop target in the rendered index space of the indicator', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n')
|
||||
setElementFromPoint(lane)
|
||||
|
||||
expect(getWorkspaceKanbanSidebarDropTarget(24, 60)).toMatchObject({
|
||||
status: 'doing',
|
||||
dropIndex: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('translates a rendered drop index onto the full lane at the commit boundary', () => {
|
||||
const { lane } = appendBoard()
|
||||
lane.dataset.workspaceLaneFullIds = ['doing-x', 'doing-a', 'doing-y', 'doing-b'].join('\n')
|
||||
setElementFromPoint(lane)
|
||||
|
||||
// Rendered index 1 means "before doing-b", which is index 3 in the full lane.
|
||||
expect(getWorkspaceKanbanSidebarDropTarget(24, 60)).toMatchObject({
|
||||
status: 'doing',
|
||||
dropIndex: 3
|
||||
})
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 1)).toBe(3)
|
||||
// Why: a tracked target can be committed after the pointer left the lane,
|
||||
// so the translation must not depend on the current pointer position.
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('doing', 2)).toBe(4)
|
||||
})
|
||||
|
||||
it('passes the drop index through for a lane it cannot find', () => {
|
||||
appendBoard()
|
||||
|
||||
expect(resolveWorkspaceKanbanSidebarFullLaneDropIndex('todo', 2)).toBe(2)
|
||||
})
|
||||
|
||||
it('marks and clears the external board hover target', () => {
|
||||
|
||||
@@ -111,24 +111,30 @@ export function getWorkspaceKanbanSidebarDropTarget(
|
||||
if (!board) {
|
||||
return { status: null, isPinDrop: false, dropIndex: 0 }
|
||||
}
|
||||
const target = getCardDropTarget(board, x, y)
|
||||
if (!target.status) {
|
||||
return target
|
||||
}
|
||||
const lane = getStatusDropTargetElement(board, target.status)
|
||||
return getCardDropTarget(board, x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a tracked drop index — which counts *rendered* cards, matching the
|
||||
* drop indicator — onto the full lane that `getWorkspaceKanbanSidebarDropGroups`
|
||||
* reports. Call this once, at the commit boundary: a tracked target can be
|
||||
* committed after the pointer has left the lane, so translating earlier would
|
||||
* miss that path.
|
||||
*/
|
||||
export function resolveWorkspaceKanbanSidebarFullLaneDropIndex(
|
||||
status: WorkspaceStatus,
|
||||
renderedDropIndex: number
|
||||
): number {
|
||||
const board = getWorkspaceKanbanBoardElement()
|
||||
const lane = board ? getStatusDropTargetElement(board, status) : null
|
||||
if (!lane) {
|
||||
return target
|
||||
}
|
||||
// Why: getCardDropTarget counts rendered cards, but the groups above are the
|
||||
// full lane. Committing the two together would interpolate the wrong rank.
|
||||
return {
|
||||
...target,
|
||||
dropIndex: resolveFullLaneDropIndex({
|
||||
fullLaneIds: getLaneFullWorktreeIds(lane),
|
||||
renderedIds: getRenderedLaneCardIds(lane),
|
||||
filteredDropIndex: target.dropIndex
|
||||
})
|
||||
return renderedDropIndex
|
||||
}
|
||||
return resolveFullLaneDropIndex({
|
||||
fullLaneIds: getLaneFullWorktreeIds(lane),
|
||||
renderedIds: getRenderedLaneCardIds(lane),
|
||||
filteredDropIndex: renderedDropIndex
|
||||
})
|
||||
}
|
||||
|
||||
export function updateWorkspaceKanbanSidebarDropTargetVisual(args: {
|
||||
|
||||
@@ -4366,7 +4366,7 @@
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "Vacío",
|
||||
"3611d1ae7f": "Cambiar tamaño de columnas del tablero de espacios de trabajo",
|
||||
"2df01a03ff": "No matches"
|
||||
"2df01a03ff": "No hay coincidencias"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "Establecer color de {{value0}} en {{value1}}",
|
||||
@@ -5004,11 +5004,11 @@
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
"bdb753c78d": "Ningún espacio de trabajo coincide",
|
||||
"4d96c209d6": "{{value0}} de {{value1}} espacios de trabajo coinciden",
|
||||
"c0cd6bdf6c": "Buscar espacios de trabajo",
|
||||
"49c266baaa": "Buscar",
|
||||
"3b7ea51793": "Borrar búsqueda"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4347,7 +4347,7 @@
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空の",
|
||||
"3611d1ae7f": "ワークスペースボードの列のサイズを変更する",
|
||||
"2df01a03ff": "No matches"
|
||||
"2df01a03ff": "一致なし"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} の色を {{value1}} に設定します",
|
||||
@@ -5004,11 +5004,11 @@
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
"bdb753c78d": "一致するワークスペースはありません",
|
||||
"4d96c209d6": "{{value1}} 件中 {{value0}} 件のワークスペースが一致します",
|
||||
"c0cd6bdf6c": "ワークスペースの検索",
|
||||
"49c266baaa": "検索",
|
||||
"3b7ea51793": "検索をクリア"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4347,7 +4347,7 @@
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "비어 있음",
|
||||
"3611d1ae7f": "워크스페이스 보드 열 크기 조정",
|
||||
"2df01a03ff": "No matches"
|
||||
"2df01a03ff": "일치하는 항목 없음"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "{{value0}} 색상을 {{value1}}로 설정",
|
||||
@@ -5004,11 +5004,11 @@
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
"bdb753c78d": "일치하는 워크스페이스가 없습니다",
|
||||
"4d96c209d6": "워크스페이스 {{value1}}개 중 {{value0}}개 일치",
|
||||
"c0cd6bdf6c": "워크스페이스 검색",
|
||||
"49c266baaa": "검색",
|
||||
"3b7ea51793": "검색 지우기"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
@@ -4347,7 +4347,7 @@
|
||||
"WorkspaceKanbanStatusLane": {
|
||||
"8ad104642b": "空的",
|
||||
"3611d1ae7f": "调整工作区板列的大小",
|
||||
"2df01a03ff": "No matches"
|
||||
"2df01a03ff": "没有匹配项"
|
||||
},
|
||||
"WorkspaceStatusAppearancePopover": {
|
||||
"514be2f569": "将 {{value0}} 颜色设置为 {{value1}}",
|
||||
@@ -5004,11 +5004,11 @@
|
||||
"parkTerminal": "Park terminal"
|
||||
},
|
||||
"WorkspaceKanbanSearchField": {
|
||||
"bdb753c78d": "No workspaces match",
|
||||
"4d96c209d6": "{{value0}} of {{value1}} workspaces match",
|
||||
"c0cd6bdf6c": "Search workspaces",
|
||||
"49c266baaa": "Search",
|
||||
"3b7ea51793": "Clear search"
|
||||
"bdb753c78d": "没有匹配的工作区",
|
||||
"4d96c209d6": "{{value1}} 个工作区中有 {{value0}} 个匹配",
|
||||
"c0cd6bdf6c": "搜索工作区",
|
||||
"49c266baaa": "搜索",
|
||||
"3b7ea51793": "清除搜索"
|
||||
}
|
||||
},
|
||||
"shared": {
|
||||
|
||||
Reference in New Issue
Block a user