Files
orca/src/shared/github/project-board-columns.test.ts
T
635e7f53cb feat(github-projects): render Board project views as a kanban with drag-and-drop (#19074)
* Add board layout support for GitHub project views

Board-layout views render as a kanban. Columns come from the view's
verticalGroupByFields (the host retries without the selection on older
GHES schemas and the renderer falls back to the Status field), with one
column per single-select option in option order — empty ones included —
plus a trailing no-value column whose drop clears the field. Card drops
reuse the table's field mutation path, committed from a document-level
capture listener because the preload's native-drop bridge stops drop
events before React's root ever sees them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(github-projects): harden board capability probe and drop lifecycle

Review follow-up. The verticalGroupByFields capability probe now matches
parsed GraphQL error messages instead of substring-scanning the whole
response body — partial-error responses echo the field name as a data key
on healthy schemas, so one SAML/FORBIDDEN partial error could permanently
degrade github.com boards for the session. Covered by new
project-view-config tests per the capability-cache testing contract.

Also: only single-select/iteration vertical fields shape columns (a
drifted field kind no longer yields a clear-on-drop no-value column),
optimistic patches resolve the column field through the board/group
config, drag cleanup moved to a document-level dragend listener, the
column dot follows dark mode via the chip CSS variables, the supported-
layout allowlist is a single shared predicate, and the board's edit
handler is stable so its drop listener stops re-registering per render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(github-projects): serialize board edits and verify rendered drops

* fix(github-projects): preserve refresh baselines and order edits across views

* fix(github-projects): accept source settings projection in cache scope

* fix(github-projects): distinguish view switches from refreshed field baselines

* test(github-projects): type board IPC recordings and verify clear request

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <neil@stably.ai>
2026-10-05 22:23:57 -07:00

187 lines
5.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildBoardColumns, resolveBoardColumnField } from './project-board-columns'
import type {
GitHubProjectField,
GitHubProjectFieldValue,
GitHubProjectRow,
GitHubProjectView
} from './project-types'
const STATUS_FIELD: GitHubProjectField = {
kind: 'single-select',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: [
{ id: 'opt_todo', name: 'Todo', color: 'GREEN' },
{ id: 'opt_prog', name: 'In Progress', color: 'YELLOW' },
{ id: 'opt_done', name: 'Done', color: 'PURPLE' }
]
}
const SPRINT_FIELD: GitHubProjectField = {
kind: 'iteration',
id: 'f_sprint',
name: 'Sprint',
dataType: 'ITERATION',
iterations: [
{ id: 'it_1', title: 'Sprint 1', startDate: '2026-08-24', duration: 14, completed: true },
{ id: 'it_2', title: 'Sprint 2', startDate: '2026-09-07', duration: 14, completed: false }
]
}
function view(overrides: Partial<GitHubProjectView>): GitHubProjectView {
return {
id: 'PVTV_1',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: '',
fields: [],
groupByFields: [],
sortByFields: [],
...overrides
}
}
function row(id: string, values: GitHubProjectFieldValue[]): GitHubProjectRow {
const fieldValuesByFieldId: Record<string, GitHubProjectFieldValue> = {}
for (const value of values) {
fieldValuesByFieldId[value.fieldId] = value
}
return {
id,
itemType: 'ISSUE',
content: {
number: 1,
title: id,
body: null,
url: `https://github.com/o/r/issues/1`,
state: 'OPEN',
stateReason: null,
isDraft: null,
repository: 'o/r',
assignees: [],
labels: [],
parentIssue: null,
issueType: null
},
fieldValuesByFieldId,
updatedAt: '2026-09-01T00:00:00Z',
position: 0
}
}
const statusValue = (optionId: string, name: string): GitHubProjectFieldValue => ({
kind: 'single-select',
fieldId: 'f_status',
optionId,
name,
color: ''
})
describe('resolveBoardColumnField', () => {
it('prefers the view-configured vertical group field', () => {
const v = view({ fields: [STATUS_FIELD], verticalGroupByFields: [SPRINT_FIELD] })
expect(resolveBoardColumnField(v)).toBe(SPRINT_FIELD)
})
it('falls back to the Status field, then any single-select', () => {
const priority: GitHubProjectField = { ...STATUS_FIELD, id: 'f_prio', name: 'Priority' }
expect(resolveBoardColumnField(view({ fields: [priority, STATUS_FIELD] }))).toBe(STATUS_FIELD)
expect(resolveBoardColumnField(view({ fields: [priority] }))).toBe(priority)
})
it('ignores a non-select vertical field instead of building clear-only columns', () => {
const textField: GitHubProjectField = {
kind: 'field',
id: 'f_text',
name: 'Notes',
dataType: 'TEXT'
}
const v = view({ fields: [STATUS_FIELD], verticalGroupByFields: [textField] })
expect(resolveBoardColumnField(v)).toBe(STATUS_FIELD)
})
it('returns null when nothing can shape columns', () => {
expect(
resolveBoardColumnField(
view({ fields: [{ kind: 'field', id: 'f_t', name: 'Title', dataType: 'TITLE' }] })
)
).toBeNull()
})
})
describe('buildBoardColumns', () => {
it('emits every option as a column in option order, empty ones included', () => {
const columns = buildBoardColumns(STATUS_FIELD, [
row('a', [statusValue('opt_done', 'Done')]),
row('b', [statusValue('opt_todo', 'Todo')])
])
expect(columns.map((c) => c.label)).toEqual(['Todo', 'In Progress', 'Done', 'No Status'])
expect(columns.map((c) => c.rows.length)).toEqual([1, 0, 1, 0])
expect(columns[0]?.dropValue).toEqual({ kind: 'single-select', optionId: 'opt_todo' })
expect(columns[0]?.color).toBe('GREEN')
})
it('routes no-value rows to the trailing column whose drop clears the field', () => {
const columns = buildBoardColumns(STATUS_FIELD, [row('a', [])])
const trailing = columns.at(-1)!
expect(trailing.label).toBe('No Status')
expect(trailing.rows.map((r) => r.id)).toEqual(['a'])
expect(trailing.dropValue).toBeNull()
})
it('keeps rows pointing at a deleted option in their own non-droppable column', () => {
const columns = buildBoardColumns(STATUS_FIELD, [
row('a', [statusValue('opt_gone', 'Archived')])
])
const ghost = columns.find((c) => c.label === 'Archived')!
expect(ghost.rows.map((r) => r.id)).toEqual(['a'])
expect(ghost.dropValue).toBeUndefined()
// Ghost columns sit between the real options and the no-value column.
expect(columns.at(-1)?.label).toBe('No Status')
})
it('builds iteration columns with iteration drops', () => {
const columns = buildBoardColumns(SPRINT_FIELD, [
row('a', [
{
kind: 'iteration',
fieldId: 'f_sprint',
iterationId: 'it_2',
title: 'Sprint 2',
startDate: '2026-09-07',
duration: 14
}
])
])
expect(columns.map((c) => c.label)).toEqual(['Sprint 1', 'Sprint 2', 'No Sprint'])
expect(columns[1]?.dropValue).toEqual({ kind: 'iteration', iterationId: 'it_2' })
expect(columns[1]?.rows.map((r) => r.id)).toEqual(['a'])
})
it('buckets non-select fields read-only', () => {
const assignees: GitHubProjectField = {
kind: 'field',
id: 'f_assignees',
name: 'Assignees',
dataType: 'ASSIGNEES'
}
const columns = buildBoardColumns(assignees, [
row('a', [
{
kind: 'users',
fieldId: 'f_assignees',
users: [{ login: 'alice', name: null, avatarUrl: null }]
}
]),
row('b', [])
])
expect(columns.map((c) => c.label)).toEqual(['alice', 'No Assignees'])
expect(columns[0]?.dropValue).toBeUndefined()
expect(columns.at(-1)?.dropValue).toBeUndefined()
expect(columns.at(-1)?.rows.map((r) => r.id)).toEqual(['b'])
})
})