mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
Neil
parent
ca4e239861
commit
635e7f53cb
@@ -0,0 +1,186 @@
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { EMPTY_PROJECT_GROUP_KEY, groupRowsByField } from './project-group-sort'
|
||||
import type {
|
||||
GitHubProjectField,
|
||||
GitHubProjectFieldMutationValue,
|
||||
GitHubProjectRow,
|
||||
GitHubProjectView
|
||||
} from './project-types'
|
||||
|
||||
export type ProjectBoardColumn = {
|
||||
/** Stable key used for React reconciliation. */
|
||||
key: string
|
||||
label: string
|
||||
/** GitHub single-select color token ('GREEN', …) when the column is one. */
|
||||
color: string | null
|
||||
/** undefined is read-only; null clears the field. */
|
||||
dropValue: GitHubProjectFieldMutationValue | null | undefined
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
export function resolveBoardColumnField(view: GitHubProjectView): GitHubProjectField | null {
|
||||
const vertical = view.verticalGroupByFields?.[0]
|
||||
// Non-select fields must never expose clear-only drop targets.
|
||||
if (vertical && (vertical.kind === 'single-select' || vertical.kind === 'iteration')) {
|
||||
return vertical
|
||||
}
|
||||
// Older hosts omit vertical grouping; Status is GitHub's default.
|
||||
const singleSelects = view.fields.filter((field) => field.kind === 'single-select')
|
||||
return singleSelects.find((field) => /^status$/i.test(field.name)) ?? singleSelects[0] ?? null
|
||||
}
|
||||
|
||||
/** Includes empty options, deleted-option buckets, and a final unset bucket. */
|
||||
export function buildBoardColumns(
|
||||
field: GitHubProjectField,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectBoardColumn[] {
|
||||
const buckets = groupRowsByField(field, rowsInOrder)
|
||||
const bucketsByKey = new Map(buckets.map((bucket) => [bucket.key, bucket]))
|
||||
const columns: ProjectBoardColumn[] = []
|
||||
if (field.kind === 'single-select') {
|
||||
for (const option of field.options) {
|
||||
columns.push({
|
||||
key: option.id,
|
||||
label: option.name,
|
||||
color: option.color || null,
|
||||
dropValue: { kind: 'single-select', optionId: option.id },
|
||||
rows: bucketsByKey.get(option.id)?.rows ?? []
|
||||
})
|
||||
bucketsByKey.delete(option.id)
|
||||
}
|
||||
} else if (field.kind === 'iteration') {
|
||||
for (const iteration of field.iterations) {
|
||||
columns.push({
|
||||
key: iteration.id,
|
||||
label: iteration.title,
|
||||
color: null,
|
||||
dropValue: { kind: 'iteration', iterationId: iteration.id },
|
||||
rows: bucketsByKey.get(iteration.id)?.rows ?? []
|
||||
})
|
||||
bucketsByKey.delete(iteration.id)
|
||||
}
|
||||
}
|
||||
// Preserve deleted options as read-only columns.
|
||||
for (const bucket of buckets) {
|
||||
if (!bucketsByKey.has(bucket.key) || bucket.key === EMPTY_PROJECT_GROUP_KEY) {
|
||||
continue
|
||||
}
|
||||
columns.push({
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
color: null,
|
||||
dropValue: undefined,
|
||||
rows: bucket.rows
|
||||
})
|
||||
}
|
||||
columns.push({
|
||||
key: EMPTY_PROJECT_GROUP_KEY,
|
||||
label: `No ${field.name}`,
|
||||
color: null,
|
||||
dropValue: field.kind === 'single-select' || field.kind === 'iteration' ? null : undefined,
|
||||
rows: bucketsByKey.get(EMPTY_PROJECT_GROUP_KEY)?.rows ?? []
|
||||
})
|
||||
return columns
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export type ProjectGroup = {
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
const EMPTY_GROUP_KEY = '__empty__'
|
||||
export const EMPTY_PROJECT_GROUP_KEY = '__empty__'
|
||||
|
||||
type ProjectFieldValue = GitHubProjectRow['fieldValuesByFieldId'][string]
|
||||
|
||||
@@ -78,7 +78,7 @@ function getFieldValueForGrouping(
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!hasNonEmptyFieldValue(value)) {
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
key: EMPTY_PROJECT_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
@@ -141,6 +141,15 @@ export function groupRows(
|
||||
if (!groupField) {
|
||||
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
|
||||
}
|
||||
return groupRowsByField(groupField, rowsInOrder)
|
||||
}
|
||||
|
||||
/** Buckets rows by an explicit field — the table view's groupBy and the board
|
||||
* view's column field share this so both order and label groups identically. */
|
||||
export function groupRowsByField(
|
||||
groupField: GitHubProjectField,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectGroup[] {
|
||||
const groupOrderIndex = createFieldOrderIndex(groupField)
|
||||
const buckets = new Map<
|
||||
string,
|
||||
@@ -167,10 +176,10 @@ export function groupRows(
|
||||
const entries = Array.from(buckets.entries())
|
||||
// Ordering rules per design doc §Grouping.
|
||||
entries.sort((a, b) => {
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
if (a[0] === EMPTY_PROJECT_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
if (b[0] === EMPTY_PROJECT_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
// would obscure ownership of the Project surface.
|
||||
|
||||
export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
|
||||
|
||||
/** Allowlist shared by the host gate, tab strip, and picker — raw.layout is
|
||||
* cast unchecked, so an unknown future GitHub layout must fail this check
|
||||
* everywhere at once rather than drifting per call site. */
|
||||
export function isRenderableProjectViewLayout(layout: string): boolean {
|
||||
return layout === 'TABLE_LAYOUT' || layout === 'BOARD_LAYOUT' || layout === 'ROADMAP_LAYOUT'
|
||||
}
|
||||
export type GitHubProjectOwnerType = 'organization' | 'user'
|
||||
|
||||
// Why: anything outside this union must render as an empty cell — the
|
||||
@@ -89,6 +96,10 @@ export type GitHubProjectView = {
|
||||
fields: GitHubProjectField[]
|
||||
groupByFields: GitHubProjectField[]
|
||||
sortByFields: GitHubProjectSort[]
|
||||
/** Board-layout column field(s). Optional for wire compat — older hosts
|
||||
* don't send it, and hosts fall back to omitting it when the GraphQL
|
||||
* schema lacks `verticalGroupByFields` (older GHES). */
|
||||
verticalGroupByFields?: GitHubProjectField[]
|
||||
}
|
||||
|
||||
export type GitHubProjectUser = {
|
||||
|
||||
Reference in New Issue
Block a user