fix(tasks): let Linear section headers collapse and expand (#34173)

* fix(tasks): make Linear list section headers collapsible

* test(tasks): cover Linear section collapse and keyboard activation

* test(tasks): bound Linear section toggle work at scale

* fix(tasks): compose Linear section surface with standard button

* fix(tasks): keep section keyboard focus inside the row

---------

Co-authored-by: Isaac Mello <dzordinetwork@gmail.com>
This commit is contained in:
Brennan Benson
2026-10-27 13:46:23 -07:00
committed by GitHub
co-authored by Isaac Mello
parent 8f3d8c8583
commit 41755b5b24
2 changed files with 240 additions and 3 deletions
@@ -0,0 +1,213 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, expect, it, vi } from 'vitest'
import type { LinearIssue } from '../../../../../shared/linear/issue-types'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { TooltipProvider } from '@/components/ui/tooltip'
import { TaskPageLinearIssueTable } from './IssueTable'
import type { LinearIssueListRow } from './task-page-linear-issue-model'
vi.mock('./task-page-linear-issue-model', () => ({ LinearStateCell: () => null }))
afterEach(cleanup)
function issue(id: string): LinearIssue {
return {
id,
identifier: `COR-${id}`,
title: `Issue ${id}`,
url: `https://linear.app/core/issue/COR-${id}`,
state: { name: 'Todo', type: 'unstarted', color: '#000' },
team: { id: 'core', name: 'Core', key: 'COR' },
labels: [],
labelIds: [],
priority: 0,
updatedAt: '2026-10-01T00:00:00Z'
}
}
const first = issue('1')
const second = issue('2')
const third = issue('3')
const rows: LinearIssueListRow[] = [
{ type: 'section', key: 'status:Todo', label: 'Todo', count: 2 },
{ type: 'issue', issue: first },
{ type: 'issue', issue: second },
{ type: 'section', key: 'status:Done', label: 'Done', count: 1 },
{ type: 'issue', issue: third }
]
function table(linearIssueListRows = rows) {
const fixture = {
selectedLinearWorkspaceId: 'all',
linearTaskSourceContext: null,
selectedLinearIssueId: null,
openLinearDetailPage: vi.fn(),
linearIssueAttachmentIndex: new Map(),
effectiveLinearDisplayProperties: new Set(),
linearIssueGridStyle: {},
linearIssueListRows,
handleOpenOrUseLinearItem: vi.fn()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture supplies every model field read by this table.
const model = fixture as unknown as TaskPageComposerActionsModel
return (
<TooltipProvider>
<TaskPageLinearIssueTable model={model} />
</TooltipProvider>
)
}
it('collapses only the clicked section, keeps counts, and expands with Enter and Space', async () => {
const user = userEvent.setup()
render(table())
const todo = screen.getByRole('button', { name: 'Todo 2', expanded: true })
const done = screen.getByRole('button', { name: 'Done 1', expanded: true })
await user.click(todo)
expect(todo.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('Issue 1')).toBeNull()
expect(screen.queryByText('Issue 2')).toBeNull()
expect(screen.getByText('Issue 3')).toBeTruthy()
expect(done.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByRole('button', { name: 'Todo 2' })).toBe(todo)
await user.keyboard('{Enter}')
expect(todo.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByText('Issue 1')).toBeTruthy()
expect(screen.getByText('Issue 2')).toBeTruthy()
await user.keyboard(' ')
expect(todo.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('Issue 1')).toBeNull()
await user.click(todo)
expect(screen.getByText('Issue 1')).toBeTruthy()
})
it('tracks the group key through refreshed rows and leaves ungrouped issues visible', async () => {
const user = userEvent.setup()
const view = render(table())
await user.click(screen.getByRole('button', { name: 'Todo 2' }))
const refreshed: LinearIssueListRow[] = [
...rows.slice(3),
{ type: 'section', key: 'status:Todo', label: 'Todo', count: 3 },
...rows.slice(1, 3),
{ type: 'issue', issue: issue('4') }
]
view.rerender(table(refreshed))
expect(screen.getByRole('button', { name: 'Todo 3', expanded: false })).toBeTruthy()
expect(screen.queryByText('Issue 4')).toBeNull()
expect(screen.getByText('Issue 3')).toBeTruthy()
view.rerender(table([{ type: 'issue', issue: first }]))
expect(screen.getByText('Issue 1')).toBeTruthy()
view.unmount()
render(table())
expect(screen.getByRole('button', { name: 'Todo 2', expanded: true })).toBeTruthy()
})
it('bounds section-click work linearly at N and 10N remembered groups and current rows', () => {
const samples = [1, 10].map((scale) => {
const history = 100 * scale
const groups = 3 * scale
const operations = { copiedKeys: 0, membershipChecks: 0, rowVisits: 0 }
function page(start: number, count: number): LinearIssueListRow[] {
return Array.from({ length: count }, (_, offset) => {
const id = start + offset
return [
{
get type() {
operations.rowVisits += 1
return 'section' as const
},
key: `assignee:perf-${id}`,
label: `Person ${id}`,
count: 1
},
{
get type() {
operations.rowVisits += 1
return 'issue' as const
},
issue: issue(`perf-${id}`)
}
]
}).flat()
}
const view = render(table([]))
// Each page stays within the shipped 36-issue limit; absent keys remain in this mount.
for (let start = 0; start < history; start += 36) {
view.rerender(table(page(start, Math.min(36, history - start))))
for (const button of view.container.querySelectorAll('button[aria-expanded]')) {
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe('false')
}
}
view.rerender(table(page(history, groups)))
const button = screen.getByRole('button', { name: `Person ${history} 1` })
const iterate = Set.prototype[Symbol.iterator]
const has = Set.prototype.has
const copied = vi.spyOn(Set.prototype, Symbol.iterator).mockImplementation(function (
this: Set<unknown>
) {
const iterator = iterate.call(this)
const next = iterator.next.bind(iterator)
iterator.next = () => {
const result = next()
if (
!result.done &&
typeof result.value === 'string' &&
result.value.startsWith('assignee:perf-')
) {
operations.copiedKeys += 1
}
return result
}
return iterator
})
const membership = vi.spyOn(Set.prototype, 'has').mockImplementation(function (
this: Set<unknown>,
value: unknown
) {
if (typeof value === 'string' && value.startsWith('assignee:perf-')) {
operations.membershipChecks += 1
}
return has.call(this, value)
})
const clicks: (typeof operations)[] = []
try {
for (const expanded of [false, true]) {
Object.assign(operations, { copiedKeys: 0, membershipChecks: 0, rowVisits: 0 })
fireEvent.click(button)
expect(button.getAttribute('aria-expanded')).toBe(String(expanded))
expect(operations.copiedKeys).toBeGreaterThanOrEqual(history)
expect(operations.copiedKeys).toBeLessThanOrEqual(2 * (history + 1))
expect(operations.membershipChecks).toBeGreaterThanOrEqual(groups)
expect(operations.membershipChecks).toBeLessThanOrEqual(2 * groups)
expect(operations.rowVisits).toBeGreaterThanOrEqual(2 * groups)
expect(operations.rowVisits).toBeLessThanOrEqual(4 * groups)
clicks.push({ ...operations })
}
} finally {
copied.mockRestore()
membership.mockRestore()
}
view.rerender(table(page(history, groups).map((row) => ({ ...row }))))
const milliseconds = Array.from({ length: 20 }, () => {
const start = performance.now()
fireEvent.click(button)
return performance.now() - start
}).sort((a, b) => a - b)
console.info(
'Linear section-click benchmark',
JSON.stringify({ history, groups, clicks, milliseconds })
)
view.unmount()
return clicks.map(
({ copiedKeys, membershipChecks, rowVisits }) => copiedKeys + membershipChecks + rowVisits
)
})
for (let click = 0; click < 2; click += 1) {
expect(samples[1][click]).toBeLessThanOrEqual(12 * samples[0][click])
}
})
@@ -1,3 +1,4 @@
import { useState } from 'react'
import type { TaskPageComposerActionsModel } from '../use-task-page-composer-actions'
import { ChevronDown, FolderOpen, ArrowRight, ExternalLink } from 'lucide-react'
import { findLinearIssueWorkspaceAttachmentInIndex } from '@/lib/linear-issue-workspace-attachment'
@@ -14,6 +15,7 @@ export function TaskPageLinearIssueTable({
}: {
model: TaskPageComposerActionsModel
}): React.JSX.Element | null {
const [collapsedSections, setCollapsedSections] = useState<ReadonlySet<string>>(() => new Set())
const {
selectedLinearWorkspaceId,
linearTaskSourceContext,
@@ -25,20 +27,42 @@ export function TaskPageLinearIssueTable({
linearIssueListRows,
handleOpenOrUseLinearItem
} = model
let sectionCollapsed = false
return (
<div className="divide-y divide-border/50">
{linearIssueListRows.map((row) => {
if (row.type === 'section') {
const collapsed = collapsedSections.has(row.key)
sectionCollapsed = collapsed
return (
<div key={row.key} className="flex h-9 items-center gap-2 bg-muted/35 px-3">
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
<button
key={row.key}
type="button"
aria-expanded={!collapsed}
onClick={() => {
setCollapsedSections((current) => {
const next = new Set(current)
if (!next.delete(row.key)) {
next.add(row.key)
}
return next
})
}}
className="flex h-9 w-full cursor-pointer items-center justify-start gap-2 rounded-md bg-muted/35 px-3 py-2 text-left text-sm font-medium whitespace-nowrap transition-all hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring dark:hover:bg-accent/50"
>
<ChevronDown
className={cn('size-3 shrink-0 text-muted-foreground', collapsed && '-rotate-90')}
/>
<span className="min-w-0 truncate text-[13px] font-medium text-foreground">
{row.label}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{row.count}</span>
</div>
</button>
)
}
if (sectionCollapsed) {
return null
}
const issue = row.issue
const selected = issue.id === selectedLinearIssueId
const labels = issue.labels.slice(0, 3)