mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* 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>
187 lines
5.8 KiB
TypeScript
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'])
|
|
})
|
|
})
|