mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37: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,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 } : {})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user