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:
NaoyaTatetsu
2026-10-05 22:23:57 -07:00
committed by GitHub
co-authored by Claude Fable 5 Neil
parent ca4e239861
commit 635e7f53cb
22 changed files with 2025 additions and 181 deletions
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchProjectViewsPage,
finalizeView,
resetVerticalGroupByCapabilityForTests,
type RawProjectView
} from './project-view-config'
import { runGraphql } from './internals'
import type * as Internals from './internals'
vi.mock('./internals', async (importOriginal) => ({
...(await importOriginal<typeof Internals>()),
runGraphql: vi.fn()
}))
const args = {
owner: 'acme',
ownerType: 'organization',
projectNumber: 1,
host: 'ghes.acme.test',
after: null
} as const
function okPage() {
return {
ok: true as const,
data: {
organization: {
projectV2: {
id: 'PVT_1',
title: 'Plan',
url: 'https://ghes.acme.test/orgs/acme/projects/1',
views: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }
}
}
}
}
}
function unknownFieldFailure() {
return {
ok: false as const,
error: { type: 'schema_drift' as const, message: 'Could not read this project view.' },
raw: {
stderr: '',
stdout: JSON.stringify({
errors: [
{
message: "Field 'verticalGroupByFields' doesn't exist on type 'ProjectV2View'"
}
]
})
}
}
}
beforeEach(() => {
vi.resetAllMocks()
resetVerticalGroupByCapabilityForTests()
})
describe('verticalGroupByFields capability fallback', () => {
it('drops the selection and retries once on an unknown-field error', async () => {
vi.mocked(runGraphql)
.mockResolvedValueOnce(unknownFieldFailure())
.mockResolvedValueOnce(okPage())
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(true)
expect(vi.mocked(runGraphql).mock.calls[0]?.[0]).toContain('verticalGroupByFields')
expect(vi.mocked(runGraphql).mock.calls[1]?.[0]).not.toContain('verticalGroupByFields')
})
it('memoizes the incapability per host and keeps other hosts unaffected', async () => {
vi.mocked(runGraphql).mockResolvedValueOnce(unknownFieldFailure()).mockResolvedValue(okPage())
await fetchProjectViewsPage(args)
await fetchProjectViewsPage(args)
// Third call overall = first call of the second fetch: no failed probe repeated.
expect(vi.mocked(runGraphql).mock.calls[2]?.[0]).not.toContain('verticalGroupByFields')
await fetchProjectViewsPage({ ...args, host: 'github.com' })
expect(vi.mocked(runGraphql).mock.calls[3]?.[0]).toContain('verticalGroupByFields')
})
it('passes unrelated failures through without retrying', async () => {
vi.mocked(runGraphql).mockResolvedValueOnce({
ok: false,
error: { type: 'network_error', message: 'Network error — check your connection.' },
raw: { stderr: 'connect ETIMEDOUT', stdout: '' }
})
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(false)
expect(vi.mocked(runGraphql)).toHaveBeenCalledTimes(1)
})
it('does not cache authorization or transient errors that mention the field', async () => {
vi.mocked(runGraphql)
.mockResolvedValueOnce({
ok: false,
error: { type: 'scope_missing', message: 'Not authorized' },
raw: {
stderr: '',
stdout: JSON.stringify({
errors: [{ message: 'Not authorized to access verticalGroupByFields' }]
})
}
})
.mockResolvedValue(okPage())
expect((await fetchProjectViewsPage(args)).ok).toBe(false)
expect(runGraphql).toHaveBeenCalledTimes(1)
await fetchProjectViewsPage(args)
expect(vi.mocked(runGraphql).mock.calls[1]?.[0]).toContain('verticalGroupByFields')
})
it('propagates a failed fallback and does not keep probing that host', async () => {
vi.mocked(runGraphql).mockResolvedValue(unknownFieldFailure())
expect((await fetchProjectViewsPage(args)).ok).toBe(false)
expect(runGraphql).toHaveBeenCalledTimes(2)
await fetchProjectViewsPage(args)
expect(runGraphql).toHaveBeenCalledTimes(3)
})
it('does not treat the field name inside partial-error DATA as incapability', async () => {
// Why: partial errors echo the whole body, where the field name appears as
// a plain data key on healthy schemas — that must not degrade the host.
vi.mocked(runGraphql).mockResolvedValueOnce({
ok: false,
error: { type: 'schema_drift', message: 'Could not read this project view.' },
raw: {
stderr: '',
stdout: JSON.stringify({
data: {
organization: { projectV2: { views: { nodes: [{ verticalGroupByFields: {} }] } } }
},
errors: [{ message: 'SAML enforcement: resource protected by organization policy' }]
})
}
})
const result = await fetchProjectViewsPage(args)
expect(result.ok).toBe(false)
expect(vi.mocked(runGraphql)).toHaveBeenCalledTimes(1)
})
})
describe('finalizeView verticalGroupByFields normalization', () => {
const base: RawProjectView = {
id: 'PVTV_1',
number: 1,
name: 'Board',
layout: 'BOARD_LAYOUT',
filter: null,
fields: { nodes: [] },
groupByFields: { nodes: [] },
sortByFields: { nodes: [] }
}
it('normalizes present vertical fields and drops invalid nodes', () => {
const finalized = finalizeView(
{
...base,
verticalGroupByFields: {
nodes: [
{
__typename: 'ProjectV2SingleSelectField',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: []
},
null,
{ __typename: 'ProjectV2Field' }
]
}
},
[]
)
expect(finalized.ok).toBe(true)
if (finalized.ok) {
expect(finalized.view.verticalGroupByFields).toEqual([
{
kind: 'single-select',
id: 'f_status',
name: 'Status',
dataType: 'SINGLE_SELECT',
options: []
}
])
}
})
it('omits the key entirely when the host never sent it (wire-compat shape)', () => {
const finalized = finalizeView(base, [])
expect(finalized.ok).toBe(true)
if (finalized.ok) {
expect('verticalGroupByFields' in finalized.view).toBe(false)
}
})
})
@@ -6,7 +6,8 @@ import type {
GitHubProjectViewLayout
} from '../../../shared/github/project-types'
import type { GitHubProjectViewError } from '../../../shared/github/project-result-types'
import { driftError } from './project-error-classification'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { driftError, extractGraphqlErrors } from './project-error-classification'
import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals'
import { normalizeField, type RawProjectV2Field } from './project-view-field-normalization'
import { FIELD_CONFIG_FRAGMENT } from './project-view-query-fragments'
@@ -37,11 +38,37 @@ export type RawProjectView = {
nodes?: (RawProjectV2Field | null)[]
}
groupByFields?: { nodes?: (RawProjectV2Field | null)[] }
verticalGroupByFields?: { nodes?: (RawProjectV2Field | null)[] }
sortByFields?: {
nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[]
}
}
// Older GHES hosts omit vertical grouping so one missing field cannot break all views.
const hostsWithoutVerticalGroupBy = new Set<string>()
/** Test-only: capability state is module-level so real runs memoize per host. */
export function resetVerticalGroupByCapabilityForTests(): void {
hostsWithoutVerticalGroupBy.clear()
}
function verticalGroupBySelection(host: string | undefined): string {
return hostsWithoutVerticalGroupBy.has(githubProjectHost(host))
? ''
: 'verticalGroupByFields(first:10) { nodes { ...FieldConfig } }'
}
function errorsIndicateVerticalGroupBy(raw: { stderr: string; stdout: string }): boolean {
// Partial-error data can echo the field name even when the schema supports it.
return extractGraphqlErrors(raw.stderr, raw.stdout).some(
(error) =>
/\bverticalGroupByFields\b/.test(error.message ?? '') &&
/(?:doesn't exist|does not exist|cannot query field|unknown field|undefined field)/i.test(
error.message ?? ''
)
)
}
export function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string {
return ownerType === 'organization' ? 'organization' : 'user'
}
@@ -65,7 +92,7 @@ export async function fetchProjectViewsPage(args: {
const root = ownerQueryRoot(args.ownerType)
const afterArg = args.after ? `, after: $after` : ''
const afterVar = args.after ? `$after:String!, ` : ''
const query = `
const buildQuery = (): string => `
query(${afterVar}$owner:String!, $num:Int!) {
${root}(login:$owner) {
projectV2(number:$num) {
@@ -79,6 +106,7 @@ export async function fetchProjectViewsPage(args: {
nodes { ...FieldConfig }
}
groupByFields(first:10) { nodes { ...FieldConfig } }
${verticalGroupBySelection(args.host)}
sortByFields(first:10) {
nodes { direction field { ...FieldConfig } }
}
@@ -93,11 +121,19 @@ export async function fetchProjectViewsPage(args: {
if (args.after) {
vars.after = args.after
}
const res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
query,
let res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
buildQuery(),
vars,
projectGhExecOptions(args.host)
)
if (!res.ok && verticalGroupBySelection(args.host) && errorsIndicateVerticalGroupBy(res.raw)) {
hostsWithoutVerticalGroupBy.add(githubProjectHost(args.host))
res = await runGraphql<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
buildQuery(),
vars,
projectGhExecOptions(args.host)
)
}
if (!res.ok) {
return res
}
@@ -189,6 +225,13 @@ export function finalizeView(
groupByFields.push(n)
}
}
const verticalGroupByFields: GitHubProjectField[] = []
for (const f of raw.verticalGroupByFields?.nodes ?? []) {
const n = normalizeField(f)
if (n) {
verticalGroupByFields.push(n)
}
}
const sortByFields: GitHubProjectSort[] = []
for (const s of raw.sortByFields?.nodes ?? []) {
if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) {
@@ -210,7 +253,9 @@ export function finalizeView(
filter: typeof raw.filter === 'string' ? raw.filter : '',
fields,
groupByFields,
sortByFields
sortByFields,
// Preserve absence for older hosts and cached payloads.
...(raw.verticalGroupByFields ? { verticalGroupByFields } : {})
}
}
}
@@ -89,19 +89,32 @@ describe('project view layout selection', () => {
expect(fetchAllItems).not.toHaveBeenCalled()
})
it.each(['BOARD_LAYOUT', 'FUTURE_LAYOUT'])(
'rejects %s without fetching items',
async (layout) => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', layout)]))
expect(
await getProjectViewTable({ ...args, viewId: 'unsupported', queryOverride: '' })
).toMatchObject({
ok: false,
error: { type: 'unsupported_layout' },
totalCount: 12
})
expect(fetchAllItems).not.toHaveBeenCalled()
expect(fetchItemsCountOnly).toHaveBeenCalledWith({ ...args, query: '' })
}
)
it('fetches board items like a table', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('board', 'BOARD_LAYOUT')]))
const result = await getProjectViewTable({ ...args, viewId: 'board' })
expect(result).toMatchObject({ ok: true, data: { selectedView: { layout: 'BOARD_LAYOUT' } } })
expect(fetchAllItems).toHaveBeenCalledWith({ ...args, query: 'status:open' })
expect(fetchItemsCountOnly).not.toHaveBeenCalled()
})
it('defaults to a board when neither a table nor a roadmap exists', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('board', 'BOARD_LAYOUT')]))
expect(await getProjectViewTable(args)).toMatchObject({
ok: true,
data: { selectedView: { id: 'board' } }
})
})
it('rejects an unknown future layout without fetching items', async () => {
vi.mocked(fetchProjectViewsPage).mockResolvedValue(page([view('unsupported', 'FUTURE_LAYOUT')]))
expect(
await getProjectViewTable({ ...args, viewId: 'unsupported', queryOverride: '' })
).toMatchObject({
ok: false,
error: { type: 'unsupported_layout' },
totalCount: 12
})
expect(fetchAllItems).not.toHaveBeenCalled()
expect(fetchItemsCountOnly).toHaveBeenCalledWith({ ...args, query: '' })
})
})
@@ -2,6 +2,7 @@ import type { GetProjectViewTableArgs } from '../../../shared/github/project-req
import type { GetProjectViewTableResult } from '../../../shared/github/project-result-types'
import type { GitHubProjectTable } from '../../../shared/github/project-types'
import { githubProjectHost } from '../../../shared/github/project-identity'
import { isRenderableProjectViewLayout } from '../../../shared/github/project-types'
import { assertPositiveInt, assertSlug } from './internals'
import {
fetchProjectViewsPage,
@@ -90,7 +91,10 @@ export async function getProjectViewTable(
// Why: `matchesSelector` only defaults to a table view, so a project whose
// views are all roadmaps resolved to nothing even though we can now render
// one. Table stays the preferred default; this is the empty-handed case.
selectedRaw = viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ?? null
selectedRaw =
viewsSeen.find((v) => v.layout === 'ROADMAP_LAYOUT') ??
viewsSeen.find((v) => v.layout === 'BOARD_LAYOUT') ??
null
}
if (!selectedRaw) {
return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } }
@@ -117,10 +121,10 @@ export async function getProjectViewTable(
const effectiveQuery =
typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter
// Why: roadmaps read the same item stream as a table — only the renderer
// differs. Allowlist, not `=== 'BOARD_LAYOUT'`: raw.layout is cast unchecked,
// so a future GitHub layout must reject cleanly, not render as a table.
if (selectedView.layout !== 'TABLE_LAYOUT' && selectedView.layout !== 'ROADMAP_LAYOUT') {
// Why: boards and roadmaps read the same item stream as a table — only the
// renderer differs. Unknown future layouts must reject cleanly, not render
// as a table.
if (!isRenderableProjectViewLayout(selectedView.layout)) {
const count = await fetchItemsCountOnly({
owner: args.owner,
ownerType: args.ownerType,
@@ -132,7 +136,9 @@ export async function getProjectViewTable(
ok: false,
error: {
type: 'unsupported_layout',
message: `Orca renders table and roadmap views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.`
// Why: the branch is type-unreachable (closed union) but runtime-real —
// raw.layout is cast unchecked, so an unknown value lands here.
message: `Orca renders table, board, and roadmap views. This is a ${String(selectedView.layout).replace('_LAYOUT', '').toLowerCase()} view.`
},
...(typeof count === 'number' ? { totalCount: count } : {})
}